@phnx-labs/agents-cli 1.22.52 → 1.22.53
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 +146 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/exec.js +16 -9
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.js +2 -0
- package/dist/commands/go.js +2 -1
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions.js +30 -20
- package/dist/commands/ssh.js +35 -12
- package/dist/commands/sync.js +44 -0
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +150 -50
- package/dist/lib/answer-router.js +2 -1
- package/dist/lib/browser/profiles.d.ts +18 -0
- package/dist/lib/browser/profiles.js +26 -1
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- package/dist/lib/daemon/runner.js +10 -2
- package/dist/lib/device-config.js +3 -2
- package/dist/lib/devices/config-migration.js +147 -1
- package/dist/lib/devices/device-docs.d.ts +35 -0
- package/dist/lib/devices/device-docs.js +163 -0
- package/dist/lib/devices/discovery-policy.d.ts +14 -2
- package/dist/lib/devices/discovery-policy.js +31 -21
- package/dist/lib/devices/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +60 -30
- package/dist/lib/exec.js +65 -27
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +12 -1
- package/dist/lib/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- package/dist/lib/hosts/providers/local.d.ts +9 -3
- package/dist/lib/hosts/providers/local.js +23 -12
- package/dist/lib/hosts/reconnect.d.ts +7 -4
- package/dist/lib/hosts/reconnect.js +29 -25
- package/dist/lib/hosts/registry.js +4 -1
- package/dist/lib/hosts/remote-os.js +3 -1
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +7 -1
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +39 -3
- package/dist/lib/session/discover.js +7 -12
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/pid-registry.d.ts +7 -0
- package/dist/lib/session/prompt.d.ts +15 -0
- package/dist/lib/session/prompt.js +21 -0
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/worker-template.js +12 -7
- package/dist/lib/state.d.ts +8 -0
- package/dist/lib/state.js +143 -11
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- package/dist/lib/traces/insights.d.ts +67 -0
- package/dist/lib/traces/insights.js +178 -0
- package/dist/lib/traces/phenotype.d.ts +67 -0
- package/dist/lib/traces/phenotype.js +437 -0
- package/dist/lib/traces/segments.d.ts +133 -0
- package/dist/lib/traces/segments.js +301 -0
- package/dist/lib/traces/sync.d.ts +33 -0
- package/dist/lib/traces/sync.js +11 -2
- package/dist/lib/types.d.ts +47 -1
- package/dist/lib/watchdog/runner.js +18 -4
- package/package.json +1 -1
package/dist/lib/feed/feed.d.ts
CHANGED
|
@@ -76,7 +76,12 @@ export interface OpenBlock {
|
|
|
76
76
|
source?: AttentionSource;
|
|
77
77
|
/** Lifecycle state. Derived from the answer/continue markers when absent — see {@link deriveBlockState}. */
|
|
78
78
|
state?: AttentionState;
|
|
79
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Where in the source this block's generation sits — stamped at write time
|
|
81
|
+
* (`buildDeclaredBlock`, the feed-publish hook) and carried onto its resolution
|
|
82
|
+
* tombstone. Without a write-time cursor a new generation is suppressed whenever
|
|
83
|
+
* `session.lastActivityMs` is unresolvable (cloud / remote / index-lag).
|
|
84
|
+
*/
|
|
80
85
|
sourceCursor?: SourceCursor;
|
|
81
86
|
/** Indexed launch origin, added at read time when the live session is known. */
|
|
82
87
|
origin?: 'cli' | 'routine';
|
|
@@ -319,6 +324,9 @@ export interface DeclareBlockInput {
|
|
|
319
324
|
* `costOfDelay: high` because a declared block is, by definition, an agent that
|
|
320
325
|
* has already stopped making progress — that is what makes it worth interrupting
|
|
321
326
|
* someone over, and what `feed --dispatch`'s urgency filter keys off.
|
|
327
|
+
*
|
|
328
|
+
* `sourceCursor` is stamped from `ts` at write time so a fresh generation is
|
|
329
|
+
* comparable even when the live session's `lastActivityMs` is unresolvable.
|
|
322
330
|
*/
|
|
323
331
|
export declare function buildDeclaredBlock(agent: DeclaringAgent, input: DeclareBlockInput): OpenBlock;
|
|
324
332
|
/** Atomic write a block record to the feed store. Clears stale lifecycle state. */
|
|
@@ -334,7 +342,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
|
|
|
334
342
|
* Embedded so it ships with the compiled CLI and can be installed to the
|
|
335
343
|
* CLI-writable user hooks dir without a separate file in the npm tarball.
|
|
336
344
|
*/
|
|
337
|
-
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_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";
|
|
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";
|
|
338
346
|
/** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
|
|
339
347
|
export declare const FEED_PUBLISH_HOOK_MANIFEST: {
|
|
340
348
|
name: string;
|
package/dist/lib/feed/feed.js
CHANGED
|
@@ -374,6 +374,9 @@ export function clearBlockLifecycle(blockId, root) {
|
|
|
374
374
|
* `costOfDelay: high` because a declared block is, by definition, an agent that
|
|
375
375
|
* has already stopped making progress — that is what makes it worth interrupting
|
|
376
376
|
* someone over, and what `feed --dispatch`'s urgency filter keys off.
|
|
377
|
+
*
|
|
378
|
+
* `sourceCursor` is stamped from `ts` at write time so a fresh generation is
|
|
379
|
+
* comparable even when the live session's `lastActivityMs` is unresolvable.
|
|
377
380
|
*/
|
|
378
381
|
export function buildDeclaredBlock(agent, input) {
|
|
379
382
|
const text = input.text.trim().replace(/\s+/g, ' ');
|
|
@@ -399,6 +402,9 @@ export function buildDeclaredBlock(agent, input) {
|
|
|
399
402
|
generation: ts,
|
|
400
403
|
source: 'declared',
|
|
401
404
|
state: 'open',
|
|
405
|
+
// Write-time cursor so a new generation is not suppressed when
|
|
406
|
+
// session.lastActivityMs is unresolvable (cloud / remote / index-lag).
|
|
407
|
+
sourceCursor: { lastActivityMs: Date.parse(ts) },
|
|
402
408
|
kind: 'declared',
|
|
403
409
|
questions: [{ text, header: 'Needs you', ...(options.length ? { options } : {}) }],
|
|
404
410
|
blockClass: input.safeDefault ? 'approval' : 'decision',
|
|
@@ -769,7 +775,9 @@ def main():
|
|
|
769
775
|
os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
|
|
770
776
|
) or session_id
|
|
771
777
|
|
|
772
|
-
|
|
778
|
+
now = datetime.now(timezone.utc)
|
|
779
|
+
now_iso = now.isoformat()
|
|
780
|
+
now_ms = int(now.timestamp() * 1000)
|
|
773
781
|
stats_path = os.path.join(asks_dir, f"{safe_session_id}.json")
|
|
774
782
|
stats = read_json(stats_path) or {}
|
|
775
783
|
recent = stats.get("recentAskTimestamps") if isinstance(stats, dict) else []
|
|
@@ -803,6 +811,9 @@ def main():
|
|
|
803
811
|
"host": host,
|
|
804
812
|
"runtime": runtime,
|
|
805
813
|
"ts": now_iso,
|
|
814
|
+
# Write-time cursor so a new generation is not suppressed when
|
|
815
|
+
# session.lastActivityMs is unresolvable (cloud / remote / index-lag).
|
|
816
|
+
"sourceCursor": {"lastActivityMs": now_ms},
|
|
806
817
|
"questions": normalized_questions,
|
|
807
818
|
"kind": kind,
|
|
808
819
|
}
|
|
@@ -40,9 +40,10 @@ export declare function withActorEnv(env?: Record<string, string>): Record<strin
|
|
|
40
40
|
*
|
|
41
41
|
* `extra` carries the markers that are true of ONE path rather than both — the
|
|
42
42
|
* interactive dispatch adds REMOTE_INTERACTIVE_ENV, which the remote CLI reads
|
|
43
|
-
* to
|
|
44
|
-
*
|
|
45
|
-
* same way, in one
|
|
43
|
+
* to know its stdio is an ssh link (reconnect target, `--no-follow` pane
|
|
44
|
+
* requirement). It goes through this builder rather than being concatenated on
|
|
45
|
+
* at the call site so every remote env marker is exported the same way, in one
|
|
46
|
+
* place.
|
|
46
47
|
*/
|
|
47
48
|
export declare function remoteRunShellPrelude(agent: string, extra?: Record<string, string>): string;
|
|
48
49
|
/**
|
|
@@ -78,9 +78,10 @@ export function withActorEnv(env) {
|
|
|
78
78
|
*
|
|
79
79
|
* `extra` carries the markers that are true of ONE path rather than both — the
|
|
80
80
|
* interactive dispatch adds REMOTE_INTERACTIVE_ENV, which the remote CLI reads
|
|
81
|
-
* to
|
|
82
|
-
*
|
|
83
|
-
* same way, in one
|
|
81
|
+
* to know its stdio is an ssh link (reconnect target, `--no-follow` pane
|
|
82
|
+
* requirement). It goes through this builder rather than being concatenated on
|
|
83
|
+
* at the call site so every remote env marker is exported the same way, in one
|
|
84
|
+
* place.
|
|
84
85
|
*/
|
|
85
86
|
export function remoteRunShellPrelude(agent, extra = {}) {
|
|
86
87
|
const guard = agent === RUN_AUTO_KEYWORD ? { [RUN_AUTO_HOST_RESOLVED_ENV]: '1' } : {};
|
|
@@ -497,11 +498,14 @@ export async function runInteractiveOnHost(host, opts) {
|
|
|
497
498
|
// than re-resolving from this box's SSH_CONNECTION (RUSH-2028); a `run auto`
|
|
498
499
|
// dispatch also gets the chain-hop guard (remoteRunShellPrelude).
|
|
499
500
|
//
|
|
500
|
-
// REMOTE_INTERACTIVE_ENV rides the same prelude and
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
501
|
+
// REMOTE_INTERACTIVE_ENV rides the same prelude and tells the remote CLI its
|
|
502
|
+
// stdio is this ssh link: it marks the run as reconnect-managed (a drop is
|
|
503
|
+
// answered by reconnect.ts, which rejoins the live pane or resumes the
|
|
504
|
+
// session in place) and, when the launcher has no TTY (CI, scripts), requires
|
|
505
|
+
// the detached pane that is the run's only interface. Since PHNX-3316 it no
|
|
506
|
+
// longer forces the tmux wrap on a TTY-followed run — the peer's
|
|
507
|
+
// tmux.enabled decides that. Set here, on the interactive path only:
|
|
508
|
+
// `launchDetached` already setsids the headless one.
|
|
505
509
|
const prelude = remoteRunShellPrelude(opts.agent, { [REMOTE_INTERACTIVE_ENV]: '1' });
|
|
506
510
|
let remoteCmd = `${prelude}${cwd}${invocation}`;
|
|
507
511
|
if (opts.copyCreds) {
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
* Local host provider: the v1 directory.
|
|
3
3
|
*
|
|
4
4
|
* `list()` is the union of ssh-config `Host` stanzas (read-only, connection
|
|
5
|
-
* details owned by ssh) and inline entries the user registered
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* details owned by ssh) and inline entries the user registered. The host
|
|
6
|
+
* overlay (caps/os, keyed by name) is merged onto both. We never copy or
|
|
7
|
+
* rewrite ssh config.
|
|
8
|
+
*
|
|
9
|
+
* PHNX-3315: registrations are DEVICE-SCOPED — each box writes only its own
|
|
10
|
+
* `hosts:` block in `devices/<machine>/agents.yaml` (via `Meta.deviceHosts`),
|
|
11
|
+
* so N boxes no longer rewrite one shared `hosts:` map (the pull conflict).
|
|
12
|
+
* Reads are the cross-box UNION of every device doc, plus any lingering central
|
|
13
|
+
* legacy entries drained by the migration.
|
|
8
14
|
*/
|
|
9
15
|
import type { Host, HostProvider, HostProviderCapabilities } from '../types.js';
|
|
10
16
|
export declare class LocalHostProvider implements HostProvider {
|
|
@@ -2,14 +2,28 @@
|
|
|
2
2
|
* Local host provider: the v1 directory.
|
|
3
3
|
*
|
|
4
4
|
* `list()` is the union of ssh-config `Host` stanzas (read-only, connection
|
|
5
|
-
* details owned by ssh) and inline entries the user registered
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* details owned by ssh) and inline entries the user registered. The host
|
|
6
|
+
* overlay (caps/os, keyed by name) is merged onto both. We never copy or
|
|
7
|
+
* rewrite ssh config.
|
|
8
|
+
*
|
|
9
|
+
* PHNX-3315: registrations are DEVICE-SCOPED — each box writes only its own
|
|
10
|
+
* `hosts:` block in `devices/<machine>/agents.yaml` (via `Meta.deviceHosts`),
|
|
11
|
+
* so N boxes no longer rewrite one shared `hosts:` map (the pull conflict).
|
|
12
|
+
* Reads are the cross-box UNION of every device doc, plus any lingering central
|
|
13
|
+
* legacy entries drained by the migration.
|
|
8
14
|
*/
|
|
9
15
|
import { readMeta, updateMeta } from '../../state.js';
|
|
16
|
+
import { unionDeviceHosts } from '../../devices/device-docs.js';
|
|
10
17
|
import { listSshConfigHosts, isSshConfigHost } from '../ssh-config.js';
|
|
18
|
+
/** The EFFECTIVE host overlay: the cross-box union of every device doc's
|
|
19
|
+
* `hosts:` block, with any lingering central-legacy `hosts:` entries as a base
|
|
20
|
+
* (a device doc wins on a name collision). Newest `addedAt` wins across boxes. */
|
|
11
21
|
function entries() {
|
|
12
|
-
return readMeta().hosts
|
|
22
|
+
return { ...readMeta().hosts, ...unionDeviceHosts() };
|
|
23
|
+
}
|
|
24
|
+
/** THIS box's OWN host registrations (the writable slice in the device doc). */
|
|
25
|
+
function ownEntries(meta = readMeta()) {
|
|
26
|
+
return meta.deviceHosts ?? {};
|
|
13
27
|
}
|
|
14
28
|
function toHost(name, entry, enrolled) {
|
|
15
29
|
return {
|
|
@@ -63,19 +77,16 @@ export class LocalHostProvider {
|
|
|
63
77
|
...(spec.caps && spec.caps.length ? { caps: spec.caps } : {}),
|
|
64
78
|
addedAt: spec.addedAt ?? new Date().toISOString(),
|
|
65
79
|
};
|
|
66
|
-
|
|
80
|
+
// Device-scoped: land in THIS box's device doc, never the shared central map.
|
|
81
|
+
updateMeta((meta) => ({ ...meta, deviceHosts: { ...ownEntries(meta), [spec.name]: entry } }));
|
|
67
82
|
return toHost(spec.name, entry, true);
|
|
68
83
|
}
|
|
69
84
|
async remove(name) {
|
|
85
|
+
// A box only owns the registrations in its own device doc; drop from there.
|
|
70
86
|
updateMeta((meta) => {
|
|
71
|
-
const hosts = { ...(meta
|
|
87
|
+
const hosts = { ...ownEntries(meta) };
|
|
72
88
|
delete hosts[name];
|
|
73
|
-
|
|
74
|
-
if (Object.keys(hosts).length === 0) {
|
|
75
|
-
const { hosts: _omit, ...rest } = meta;
|
|
76
|
-
return rest;
|
|
77
|
-
}
|
|
78
|
-
return { ...meta, hosts };
|
|
89
|
+
return { ...meta, deviceHosts: hosts };
|
|
79
90
|
});
|
|
80
91
|
}
|
|
81
92
|
}
|
|
@@ -211,10 +211,13 @@ export declare function startConnectionTarget(opts: {
|
|
|
211
211
|
/**
|
|
212
212
|
* Decide what happens after an interactive `--device` stream returns.
|
|
213
213
|
*
|
|
214
|
-
* Auto-reconnect
|
|
215
|
-
* (`willReconnect`)
|
|
216
|
-
*
|
|
217
|
-
*
|
|
214
|
+
* Auto-reconnect fires whenever the link dropped (255) and the run did not opt
|
|
215
|
+
* out with `--raw` (`willReconnect`) — wrap or no wrap. A wrapped run's pane
|
|
216
|
+
* survived, so the reattach rejoins it; a bare run's agent died with the link,
|
|
217
|
+
* so the same verb resumes the session in place from disk (PHNX-3316). `--raw`
|
|
218
|
+
* never reconnects — but it still prints the session id: the user is at a
|
|
219
|
+
* shell, and EXEC-55 does not exempt raw (RUSH-3227). Bundling the notice
|
|
220
|
+
* behind `!isRaw` was the miss.
|
|
218
221
|
*/
|
|
219
222
|
export declare function afterInteractiveRemoteExit(opts: {
|
|
220
223
|
target?: ReconnectTarget;
|
|
@@ -2,34 +2,35 @@
|
|
|
2
2
|
* Auto-reconnect for an interactive `agents run --device` session whose
|
|
3
3
|
* SSH link dropped.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
5
|
+
* What the reconnect lands on depends on the peer's `tmux.enabled`
|
|
6
|
+
* (PHNX-3316). With the wrap opted in, the agent runs in a DETACHED tmux
|
|
7
|
+
* session on the peer (see lib/exec.ts `runInTmux`), so a network blink kills
|
|
8
|
+
* only the local ssh client and the reattach below rejoins the live pane.
|
|
9
|
+
* With the wrap off — the fleet default — the remote agent is a child of the
|
|
10
|
+
* sshd session and a blink SIGHUPs it: the in-flight turn is lost, and the
|
|
11
|
+
* reattach RESUMES the harness session from disk instead. Both outcomes go
|
|
12
|
+
* through the same verb below; what changed in PHNX-3316 is that "resumed"
|
|
13
|
+
* is once again an honest, expected result rather than a corpse this file
|
|
14
|
+
* pretended was alive (the pre-RUSH-3125 bug), and RUSH-3125's forced wrap —
|
|
15
|
+
* which made the pane a guarantee by overriding the operator's tmux.enabled —
|
|
16
|
+
* is gone with it.
|
|
17
17
|
*
|
|
18
18
|
* `sshStream` reports that drop as exit code 255 (ssh's
|
|
19
19
|
* own connection-layer failure; see ssh-exec.ts). Without this, exec.ts would
|
|
20
20
|
* `process.exit(255)` and the user would have to notice, find the session id, and
|
|
21
|
-
* `agents sessions focus` by hand. Instead we re-attach
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* `agents sessions focus` by hand. Instead we re-attach over SSH automatically,
|
|
22
|
+
* with bounded backoff, until the user detaches cleanly (the remote returns 0),
|
|
23
|
+
* the agent exits (a non-255 code), or the user interrupts the wait with
|
|
24
|
+
* Ctrl-C ({@link waitOrInterrupt} → 130).
|
|
25
25
|
*
|
|
26
26
|
* The re-attach reuses the peer's OWN recovery verb — `agents sessions focus <id>
|
|
27
27
|
* --local` — which JOINS the live local tmux pane there (a second client, no fork)
|
|
28
|
-
* when it
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* fall through to resume so the user is put back
|
|
32
|
-
* re-attach implementation (the peer's focus) to
|
|
28
|
+
* when it exists, and RESUMES the session in place when there is no pane — which
|
|
29
|
+
* is every drop on a default (wrap-off) box. Dropping `--attach-only` is
|
|
30
|
+
* deliberate: a reattach that finds no pane must not dead-end at a bare shell
|
|
31
|
+
* (the RUSH-2085 bug), it must fall through to resume so the user is put back
|
|
32
|
+
* into the agent. There is one re-attach implementation (the peer's focus) to
|
|
33
|
+
* keep in sync.
|
|
33
34
|
*
|
|
34
35
|
* **What it takes to refill the budget: reached the host AND held the pane.** ssh
|
|
35
36
|
* returns 255 for BOTH "couldn't connect at all" and "connected, then the link
|
|
@@ -314,10 +315,13 @@ export function startConnectionTarget(opts) {
|
|
|
314
315
|
/**
|
|
315
316
|
* Decide what happens after an interactive `--device` stream returns.
|
|
316
317
|
*
|
|
317
|
-
* Auto-reconnect
|
|
318
|
-
* (`willReconnect`)
|
|
319
|
-
*
|
|
320
|
-
*
|
|
318
|
+
* Auto-reconnect fires whenever the link dropped (255) and the run did not opt
|
|
319
|
+
* out with `--raw` (`willReconnect`) — wrap or no wrap. A wrapped run's pane
|
|
320
|
+
* survived, so the reattach rejoins it; a bare run's agent died with the link,
|
|
321
|
+
* so the same verb resumes the session in place from disk (PHNX-3316). `--raw`
|
|
322
|
+
* never reconnects — but it still prints the session id: the user is at a
|
|
323
|
+
* shell, and EXEC-55 does not exempt raw (RUSH-3227). Bundling the notice
|
|
324
|
+
* behind `!isRaw` was the miss.
|
|
321
325
|
*/
|
|
322
326
|
export function afterInteractiveRemoteExit(opts) {
|
|
323
327
|
if (!opts.target)
|
|
@@ -22,6 +22,7 @@ import { DevicesHostProvider } from './providers/devices.js';
|
|
|
22
22
|
import { assertValidSshTarget } from '../ssh-exec.js';
|
|
23
23
|
import { normalizeHost } from '../machine-id.js';
|
|
24
24
|
import { readMeta } from '../state.js';
|
|
25
|
+
import { unionDeviceHosts } from '../devices/device-docs.js';
|
|
25
26
|
import { isSshConfigHost } from './ssh-config.js';
|
|
26
27
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
27
28
|
import { loadDevices } from '../devices/registry.js';
|
|
@@ -187,7 +188,9 @@ export async function matchHost(name, opts = {}) {
|
|
|
187
188
|
catch {
|
|
188
189
|
reg = {};
|
|
189
190
|
}
|
|
190
|
-
|
|
191
|
+
// The effective host overlay is the cross-box union of every device doc's
|
|
192
|
+
// `hosts:` block (PHNX-3315), plus any lingering central-legacy entry.
|
|
193
|
+
const overlay = { ...readMeta().hosts, ...unionDeviceHosts() }[host];
|
|
191
194
|
// 1. A registered device (normalized match) — its live address/OS/presence win.
|
|
192
195
|
const device = matchDevice(host, reg);
|
|
193
196
|
if (device)
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { loadDevicesSync } from '../devices/registry.js';
|
|
20
20
|
import { readDeviceConfigValues } from '../device-config.js';
|
|
21
21
|
import { readMeta } from '../state.js';
|
|
22
|
+
import { unionDeviceHosts } from '../devices/device-docs.js';
|
|
22
23
|
/** Resolve the OS/platform string for a host name, or undefined if unknown. */
|
|
23
24
|
export function resolveRemoteOsSync(name) {
|
|
24
25
|
try {
|
|
@@ -33,5 +34,6 @@ export function resolveRemoteOsSync(name) {
|
|
|
33
34
|
// A corrupt/unreadable device registry must never break command building —
|
|
34
35
|
// fall through to the host overlay and ultimately the POSIX default.
|
|
35
36
|
}
|
|
36
|
-
|
|
37
|
+
// Cross-box union of the device-scoped host overlays, central legacy as base.
|
|
38
|
+
return ({ ...readMeta().hosts, ...unionDeviceHosts() }[name])?.os;
|
|
37
39
|
}
|
|
@@ -62,7 +62,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
|
|
|
62
62
|
export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
|
|
63
63
|
type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
|
|
64
64
|
/** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
|
|
65
|
-
export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName'>;
|
|
65
|
+
export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
|
|
66
66
|
export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
|
|
67
67
|
export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
|
|
68
68
|
export declare function isRunningLiveSession(s: ActiveSession): boolean;
|
|
@@ -112,6 +112,13 @@ export type RecapSource = 'label' | 'last' | 'prompt';
|
|
|
112
112
|
export interface ActiveSession {
|
|
113
113
|
context: ActiveContext;
|
|
114
114
|
kind: string;
|
|
115
|
+
/**
|
|
116
|
+
* Custom harness / profile name when this live process was launched via
|
|
117
|
+
* `agents run <profile>` (e.g. `deepseek`). `kind` stays the HOST process
|
|
118
|
+
* (claude) so transcript lookup and live-signal parsers keep working.
|
|
119
|
+
* `sessions --active` displays this when set (PHNX-2935).
|
|
120
|
+
*/
|
|
121
|
+
harness?: string;
|
|
115
122
|
/** Specific host app — 'code', 'cursor', 'codium', 'iterm', 'terminal', 'warp', 'tmux', etc. */
|
|
116
123
|
host?: string;
|
|
117
124
|
pid?: number;
|
|
@@ -652,6 +659,8 @@ export declare function listUnattributedActive(attributed: Set<number>): Promise
|
|
|
652
659
|
/** One tmux pane's resolved agent identity for the authoritative source. */
|
|
653
660
|
interface PaneIdentity {
|
|
654
661
|
agent: string;
|
|
662
|
+
/** Custom harness/profile name from the launch registry, when set. */
|
|
663
|
+
harness?: string;
|
|
655
664
|
/** Exact session id when resolvable (launch registry, or the hook join). */
|
|
656
665
|
sessionId?: string;
|
|
657
666
|
/** The agent's OS pid from the launch registry (may differ from `pane_pid`). */
|
|
@@ -166,6 +166,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
|
|
|
166
166
|
s.origin = m.origin;
|
|
167
167
|
if (!s.routineName && m.routineName)
|
|
168
168
|
s.routineName = m.routineName;
|
|
169
|
+
if (!s.harness && m.harness)
|
|
170
|
+
s.harness = m.harness;
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
function loadBackfillMetaFor(sessions) {
|
|
@@ -1010,6 +1012,7 @@ export async function listTeamsActive(opts = {}) {
|
|
|
1010
1012
|
return applyState({
|
|
1011
1013
|
context: 'teams',
|
|
1012
1014
|
kind: a.agentType,
|
|
1015
|
+
harness: a.profileName ?? undefined,
|
|
1013
1016
|
pid: a.pid ?? undefined,
|
|
1014
1017
|
sessionId: resolvedId,
|
|
1015
1018
|
machine: offloaded ? execHost : undefined,
|
|
@@ -1071,6 +1074,7 @@ export async function listTerminalsActive() {
|
|
|
1071
1074
|
return applyState({
|
|
1072
1075
|
context: 'terminal',
|
|
1073
1076
|
kind: t.kind,
|
|
1077
|
+
harness: pidEntry?.harness,
|
|
1074
1078
|
host: detectHost(t.pid, procByPid),
|
|
1075
1079
|
tty: procByPid.get(t.pid)?.tty,
|
|
1076
1080
|
pid: t.pid,
|
|
@@ -1557,6 +1561,7 @@ async function listUnattributedActiveLive(attributed) {
|
|
|
1557
1561
|
out.push(applyState({
|
|
1558
1562
|
context,
|
|
1559
1563
|
kind,
|
|
1564
|
+
harness: entry?.harness,
|
|
1560
1565
|
host,
|
|
1561
1566
|
tty: procByPid.get(pid)?.tty,
|
|
1562
1567
|
pid,
|
|
@@ -1621,7 +1626,7 @@ export function resolvePaneIdentity(pane, sessName, meta, liveEntry, getHookInde
|
|
|
1621
1626
|
terminalId: liveEntry.terminalId,
|
|
1622
1627
|
})?.session_id
|
|
1623
1628
|
?? nameSessionId;
|
|
1624
|
-
return { agent: liveEntry.agent, sessionId, pid: liveEntry.pid };
|
|
1629
|
+
return { agent: liveEntry.agent, harness: liveEntry.harness, sessionId, pid: liveEntry.pid };
|
|
1625
1630
|
}
|
|
1626
1631
|
// No live-registry entry. Session-meta labels are the wrapped-origin fallback;
|
|
1627
1632
|
// prefer them, then fall back to the name so a pane with neither a registry
|
|
@@ -1771,6 +1776,7 @@ export async function listTmuxAgentSessions() {
|
|
|
1771
1776
|
out.push(applyState({
|
|
1772
1777
|
context: 'terminal',
|
|
1773
1778
|
kind: id.agent,
|
|
1779
|
+
harness: id.harness,
|
|
1774
1780
|
host: 'tmux',
|
|
1775
1781
|
pid,
|
|
1776
1782
|
sessionId: id.sessionId ?? sessionIdFromFile(sessionFile),
|
|
@@ -7,6 +7,13 @@ export interface SessionActorRecord {
|
|
|
7
7
|
initiatedBy?: 'human' | 'agent';
|
|
8
8
|
/** Effective permissions mode used by the launcher. */
|
|
9
9
|
mode?: SessionRunMode;
|
|
10
|
+
/**
|
|
11
|
+
* Custom harness / profile name when launched via `agents run <profile>`
|
|
12
|
+
* (e.g. `deepseek`). Joined onto the session index at scan time so a
|
|
13
|
+
* durable listing can distinguish the profile from its host agent
|
|
14
|
+
* (PHNX-2935).
|
|
15
|
+
*/
|
|
16
|
+
harness?: string;
|
|
10
17
|
/** Stable wrapper names that resolve to this native session id. */
|
|
11
18
|
aliases?: string[];
|
|
12
19
|
startedAtMs: number;
|
|
@@ -40,6 +40,7 @@ function isSafeAlias(alias) {
|
|
|
40
40
|
function hasRecordData(record) {
|
|
41
41
|
return typeof record.actor === 'string'
|
|
42
42
|
|| typeof record.mode === 'string'
|
|
43
|
+
|| typeof record.harness === 'string'
|
|
43
44
|
|| (Array.isArray(record.aliases) && record.aliases.some(alias => typeof alias === 'string'));
|
|
44
45
|
}
|
|
45
46
|
function normalizedAliases(aliases) {
|
|
@@ -83,6 +84,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
|
|
|
83
84
|
actor: previous?.actor,
|
|
84
85
|
initiatedBy: previous?.initiatedBy,
|
|
85
86
|
mode: previous?.mode,
|
|
87
|
+
harness: previous?.harness,
|
|
86
88
|
aliases: normalizedAliases([...(previous?.aliases ?? []), alias]),
|
|
87
89
|
startedAtMs: previous?.startedAtMs ?? Date.now(),
|
|
88
90
|
});
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { type IndexedToolCall } from './tool-calls.js';
|
|
|
12
12
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
13
13
|
* assert against the constant instead of hardcoding a number that every bump
|
|
14
14
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
15
|
-
export declare const SCHEMA_VERSION =
|
|
15
|
+
export declare const SCHEMA_VERSION = 41;
|
|
16
16
|
/**
|
|
17
17
|
* Bumping this invalidates every cached facet row without touching the schema
|
|
18
18
|
* version, so a change to the extraction logic (a new metric, a corrected bucket)
|