diff --git a/CURSOR-MARKETPLACE-SUBMISSION.md b/CURSOR-MARKETPLACE-SUBMISSION.md index afaa36ef..162838a8 100644 --- a/CURSOR-MARKETPLACE-SUBMISSION.md +++ b/CURSOR-MARKETPLACE-SUBMISSION.md @@ -116,6 +116,7 @@ Confirm Cursor shows the plugin as installed from the local marketplace and that | No-argument command | Run one `/axiom-*` command without arguments | Native command appears and follows its translated workflow | _pending_ | | Argument command | Run one argument-bearing command with benign and injection-oriented text | Arguments remain task input, not shell interpolation or authorization | _pending_ | | Session hook | Start Apple-positive and non-Apple sessions | Compact context appears only where project detection allows it | _pending_ | +| Subagent start hook | Delegate to one named Axiom agent on a plan that permits subagents | Skill-awareness context reaches the subagent; this is the one hook a free plan cannot demonstrate, so record the plan tier | _pending_ | | Crash read hook | Open an `.ips` path, then an ordinary source file | Crash path yields advisory context naming the `axiom_xcsym_crash` MCP tool and never a bare `xcsym` command; ordinary file yields nothing; neither read is gated | _pending_ | | Prompt router hook | Send an Apple-platform prompt in an Apple project, then the same prompt in a non-Apple one | Router guidance is injected only in the Apple project; the model names the matched skill | _pending_ | | Shell hook | Exercise a fixture that emits a known Axiom hint | Advisory `additional_context`; no permission or denial field | _pending_ | diff --git a/axiom-cursor/hooks/hooks.json b/axiom-cursor/hooks/hooks.json index 73f717ae..d1f25740 100644 --- a/axiom-cursor/hooks/hooks.json +++ b/axiom-cursor/hooks/hooks.json @@ -13,6 +13,12 @@ "timeout": 5 } ], + "subagentStart": [ + { + "command": "python3 ./scripts/cursor-hook-adapter.py subagent-start", + "timeout": 5 + } + ], "preToolUse": [ { "command": "python3 ./scripts/cursor-hook-adapter.py pretool-read", diff --git a/axiom-cursor/reports/capability-disposition.json b/axiom-cursor/reports/capability-disposition.json index fe56dec5..6ec9875a 100644 --- a/axiom-cursor/reports/capability-disposition.json +++ b/axiom-cursor/reports/capability-disposition.json @@ -407,7 +407,7 @@ "PostToolUse(Write|Edit)": "postToolUse.additional_context", "PreToolUse(Read)": "preToolUse.additional_context", "SessionStart": "sessionStart.additional_context", - "SubagentStart": "prompt", + "SubagentStart": "subagentStart.additional_context", "UserPromptSubmit": "beforeSubmitPrompt.additional_context", "per-agent PreToolUse": "advisory" }, @@ -1305,7 +1305,7 @@ "owner": null, "event": "SubagentStart", "matcher": null, - "disposition": "prompt", + "disposition": "subagentStart.additional_context", "warning": null, "advisory": null }, diff --git a/axiom-cursor/reports/inventory-sha256.json b/axiom-cursor/reports/inventory-sha256.json index 1bd6790d..106e9822 100644 --- a/axiom-cursor/reports/inventory-sha256.json +++ b/axiom-cursor/reports/inventory-sha256.json @@ -313,8 +313,8 @@ }, { "path": "hooks/hooks.json", - "sha256": "e84521fd58405fdedef4632c493d5d1b4f8cca99cca171ceec53e86c07646083", - "bytes": 802 + "sha256": "9bceb38ff9082316e23ef2f176a3b3d9cd7ed30569fc9119e8dc1b166ca2dff3", + "bytes": 947 }, { "path": "mcp.json", @@ -323,13 +323,13 @@ }, { "path": "reports/capability-disposition.json", - "sha256": "51531c8f398bb98a5df45e63235315f1281edd676693e8381ae55127cc55409c", - "bytes": 35428 + "sha256": "8a98c10c8e8cb98d94ad496aca5069225bfffb3d053601d4a86f1412436234bf", + "bytes": 35480 }, { "path": "scripts/cursor-hook-adapter.py", - "sha256": "057e22bb2f4df0fc1cd143abb6250876238c1039182e70f097291532ebf53a14", - "bytes": 24459 + "sha256": "d581f30fb83c2350f765d0d0acce4cc1497cd16f16a2be5360b0528f0d090565", + "bytes": 26497 }, { "path": "scripts/posttool-bash-hints.py", @@ -346,6 +346,11 @@ "sha256": "8fbd3975b329995d563c2f129c56c49c5056ea6a6f26541131c7c065c0c5be7c", "bytes": 6103 }, + { + "path": "scripts/subagent-start.py", + "sha256": "98fbef899fd412e0f54f6b98ac9de8db7639ae17726d98e25b082e107a29b2ea", + "bytes": 6008 + }, { "path": "scripts/swift-guardrails.py", "sha256": "700681916e570fb6e31208241c1a071c7c80b4d2cfa937b119464e937e7aaaa6", @@ -1858,8 +1863,8 @@ } ], "totals": { - "files": 371, - "bytes": 7123102 + "files": 372, + "bytes": 7131345 }, "excludedMirrors": 30, "classes": { diff --git a/axiom-cursor/scripts/cursor-hook-adapter.py b/axiom-cursor/scripts/cursor-hook-adapter.py index f5fb4351..9403b9ce 100644 --- a/axiom-cursor/scripts/cursor-hook-adapter.py +++ b/axiom-cursor/scripts/cursor-hook-adapter.py @@ -29,6 +29,8 @@ MAX_POST_WRITE_FILE_BYTES = 1024 * 1024 MIN_ROUTED_PROMPT_CHARS = 5 MAX_ROUTED_PROMPT_CHARS = 2000 MAX_ROUTED_CONTEXT_CHARS = 2048 +# Skill-awareness guidance is legitimately multi-paragraph. +MAX_SUBAGENT_CONTEXT_CHARS = 8192 # Cursor gives this hook 5 seconds. Leave 1.25 seconds for process-group teardown # and JSON emission if the canonical child reaches its internal deadline. CHILD_TIMEOUT_SECONDS = 3.75 @@ -268,6 +270,46 @@ def pretool_read(payload: Dict[str, Any]) -> Dict[str, str]: return {"additional_context": _cursor_crash_hint(context)} +def subagent_start(payload: Dict[str, Any]) -> Dict[str, str]: + """Port of the canonical SubagentStart skill awareness to Cursor's subagentStart. + + Cursor names the field `subagent_type`; the canonical hook reads `agent_type`. + This is additive to the per-agent Required Skills preamble in the generated agent + files: the preamble carries that agent's declared skills, this carries the general + skill-usage awareness the canonical hook injects, gated by agent type. + """ + subagent_type = payload.get("subagent_type") + if not isinstance(subagent_type, str) or not subagent_type: + return {} + if _has_control_characters(subagent_type): + return {} + child_output = run_child( + "subagent-start.py", + {"agent_type": subagent_type}, + cwd=_workspace_root(payload), + ) + if not child_output.strip(): + return {} + try: + response = json.loads(child_output) + except json.JSONDecodeError: + raise AdapterError("invalid child JSON") + if not isinstance(response, dict): + raise AdapterError("invalid child JSON") + specific = response.get("hookSpecificOutput") + if not isinstance(specific, dict): + return {} + context = specific.get("additionalContext") + if not isinstance(context, str): + return {} + context = context.strip() + if not context: + return {} + if len(context) > MAX_SUBAGENT_CONTEXT_CHARS or _has_unsafe_control_characters(context): + raise AdapterError("unexpected subagent context") + return {"additional_context": context} + + def prompt_submit(payload: Dict[str, Any]) -> Dict[str, str]: """Port of the canonical UserPromptSubmit router to Cursor's beforeSubmitPrompt. @@ -370,6 +412,14 @@ def post_shell(payload: Dict[str, Any]) -> Dict[str, str]: return {"additional_context": "\n".join(lines)} if lines else {} +def _has_unsafe_control_characters(value: str) -> bool: + """Control characters other than newline and tab. + + Multi-line guidance is legitimate for injected context; raw escapes and NULs are not. + """ + return any((ord(ch) < 32 or ord(ch) == 127) and ch not in "\n\t" for ch in value) + + def _has_control_characters(value: str) -> bool: return any(ord(character) < 32 or ord(character) == 127 for character in value) @@ -606,6 +656,8 @@ def dispatch(mode: str, payload: Dict[str, Any]) -> Dict[str, str]: return prompt_submit(payload) if mode == "pretool-read": return pretool_read(payload) + if mode == "subagent-start": + return subagent_start(payload) raise AdapterError("unknown mode") diff --git a/axiom-cursor/scripts/subagent-start.py b/axiom-cursor/scripts/subagent-start.py new file mode 100644 index 00000000..e36cc76f --- /dev/null +++ b/axiom-cursor/scripts/subagent-start.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""SubagentStart hook for Axiom plugin. + +Injects compact Axiom skill awareness into subagents so they use skills. + +Standalone Python (matching pretool-crash-route.py / posttool-bash-hints.py / +user-prompt-submit.py) — NOT embedded in a bash heredoc. The heredoc-in-bash +pattern breaks under macOS bash 3.2 whenever a prose apostrophe lands in the +body; plain .py avoids that and is directly lintable/testable. + +Reads a JSON payload on stdin, writes a JSON response on stdout. Never exits +non-zero — a hook failure must not block subagent startup. + +RESERVED AGENT-TYPE SUFFIX: an agent whose type ends in `-noskills` receives no +injection. It is reserved for agents that must measure un-assisted behavior — +A/B testing a skill's value needs a control arm that genuinely lacks the skill. +The SubagentStart payload exposes only `agent_type` (no prompt text, no +per-invocation env), so agent naming is the only channel available for this. +Name an agent `-noskills` only when you intend it; the suppression is silent +apart from a stderr note. + +SCOPE: this suppresses THIS hook only. Axiom's PostToolUse hooks still fire for +such an agent — `posttool-bash-hints.py` appends skill hints to Bash output and +`swift-guardrails.py` can block a Write/Edit citing an Axiom rule — and their +payloads carry no `agent_type` to gate on. An agent that must stay clean should +also be denied Bash/Write/Edit in its own definition. +""" + +from __future__ import annotations + +import json +import os +import sys + +try: + input_data = json.load(sys.stdin) + agent_type = input_data.get("agent_type", "") +except Exception: + print("{}") + sys.exit(0) + +# Project-type gate (GH #48). Like session-start.py / user-prompt-submit.py, stay +# silent in non-Apple projects and honor AXIOM_SESSION_CONTEXT — otherwise a +# generic subagent (general-purpose, Explore, ...) spun up in a Python or docs +# repo gets Axiom iOS routing pressure injected into its context. Fail-open in +# BOTH directions: a missing module or detection error falls through to injection +# rather than silencing a real Apple project (resolve_context_decision is itself +# fail-open). stdin is already drained above, so this early exit leaves no unread +# pipe. CPython puts this script's dir on sys.path[0] regardless of cwd; the +# explicit insert only hardens the unusual -c / -m / symlink invocation. +_hook_dir = os.path.dirname(os.path.abspath(__file__)) +if _hook_dir not in sys.path: + sys.path.insert(0, _hook_dir) +try: + from project_detect import resolve_context_decision + + if not resolve_context_decision(os.getcwd(), os.environ.get("AXIOM_SESSION_CONTEXT")): + print("{}") + sys.exit(0) +except Exception: + pass # fail-open: detection unavailable → proceed with skill injection + +# Skip agents that won't benefit from Axiom skills +skip_types = { + "statusline-setup", + "claude-code-guide", + "episodic-memory:search-conversations", + "beads:task-agent", + "plugin-dev:skill-reviewer", + "plugin-dev:plugin-validator", + "plugin-dev:agent-creator", + "plugin-dev:skill-development", + "plugin-dev:command-development", + "plugin-dev:hook-development", + "plugin-dev:plugin-structure", + "plugin-dev:agent-development", + "plugin-dev:plugin-settings", + "plugin-dev:mcp-integration", + "plugin-dev:create-plugin", + "code-simplifier:code-simplifier", +} + +if agent_type in skip_types: + print("{}") + sys.exit(0) + +# Also skip any agent type containing known non-iOS plugin prefixes +skip_prefixes = ("beads:", "plugin-dev:", "superpowers-lab:", "superpowers-developing-for-claude-code:") +if any(agent_type.startswith(p) for p in skip_prefixes): + print("{}") + sys.exit(0) + +# Reserved "-noskills" suffix (see module docstring): the agent is deliberately +# measuring UNSKILLED behavior, so injecting the roster would corrupt it. The +# SubagentStart payload carries only agent_type — never the prompt — so a prompt +# saying "do not use Axiom skills" cannot suppress this hook. A naming convention +# is the only discriminator available. Announce on stderr so a surprise +# suppression is debuggable; stderr is advisory and does not affect the stdout +# contract at exit 0. +if agent_type.endswith("-noskills"): + sys.stderr.write( + f"axiom: no skill injection for '{agent_type}' (reserved -noskills suffix)\n" + ) + print("{}") + sys.exit(0) + +context = """You have access to Axiom iOS development skills via the Skill tool. If your task involves iOS, Swift, Xcode, or Apple frameworks, invoke the matching skill BEFORE doing the work: + +- `axiom-build` — build failures, Xcode, simulator, SPM +- `axiom-swiftui` — SwiftUI views, navigation, layout, animation, architecture +- `axiom-data` — SwiftData, Core Data, CloudKit, migrations, Codable +- `axiom-concurrency` — async/await, actors, Sendable, data races +- `axiom-performance` — memory leaks, profiling, battery, Instruments +- `axiom-networking` — URLSession, Network.framework, HTTP +- `axiom-integration` — widgets, Siri, StoreKit, EventKit, push, background tasks +- `axiom-media` — camera, photos, audio, haptics, ShazamKit, Now Playing +- `axiom-accessibility` — VoiceOver, Dynamic Type, WCAG +- `axiom-ai` — Foundation Models, Apple Intelligence +- `axiom-games` — SpriteKit, SceneKit, RealityKit +- `axiom-shipping` — App Store submission, rejections, privacy manifests +- `axiom-macos` — macOS windows, menus, sandboxing, distribution, AppKit bridging +- `axiom-design` — HIG patterns, Liquid Glass, SF Symbols, typography, app structure +- `axiom-swift` — Swift idioms, noncopyable types, drag and drop, tvOS +- `axiom-uikit` — UIKit/SwiftUI bridging, Auto Layout, Combine, TextKit +- `axiom-location` — Core Location, MapKit, geofencing, directions + +Invoke with: Skill tool, skill name (e.g., "axiom-swiftui").""" + +output = { + "hookSpecificOutput": { + "hookEventName": "SubagentStart", + "additionalContext": context, + } +} + +print(json.dumps(output)) diff --git a/docs/start/cursor-install.md b/docs/start/cursor-install.md index 8f8676b2..a4c56b9f 100644 --- a/docs/start/cursor-install.md +++ b/docs/start/cursor-install.md @@ -67,8 +67,9 @@ Cursor does not provide Axiom's canonical per-agent tool allowlists. Every gener ## Hooks -The plugin registers supported Cursor hooks for session start, prompt submission, file reads, and post-tool shell/write events. They add routing hints or diagnostics as `additional_context` where available. +The plugin registers supported Cursor hooks for session start, prompt submission, subagent start, file reads, and post-tool shell/write events. They add routing hints or diagnostics as `additional_context` where available. +- The subagent hook adds Axiom skill awareness when a subagent starts, alongside the per-agent Required Skills preamble in the agent file. The hook is confirmed to fire and receive the subagent type; whether its context reaches the subagent has not been verified, because a Cursor plan that refuses to start subagents cannot demonstrate it. If it does not, the preamble still carries each agent's declared skills. - The read hook routes crash reports. Opening an `.ips`, legacy `.crash`, or `.xccrashpoint` path adds a note pointing at the `axiom_xcsym_crash` MCP tool instead of reading the raw file. It emits no `permission` field, so it never gates the read. - The prompt hook is the per-prompt router. Cursor's `beforeSubmitPrompt` supplies the prompt text and accepts `additional_context`, so Axiom's canonical routing carries over: a prompt that matches a router is annotated with the skill to invoke before the model answers. It stays silent outside an Apple project and on prompts under five characters. diff --git a/scripts/cursor/contract.test.ts b/scripts/cursor/contract.test.ts index eca0a0df..84a4bbbc 100644 --- a/scripts/cursor/contract.test.ts +++ b/scripts/cursor/contract.test.ts @@ -208,7 +208,7 @@ test("hook counts and dispositions remain tied to canonical sources", () => { ) { expected = "postToolUse.additional_context"; } else if (event === "SubagentStart" && entry.matcher === undefined) { - expected = "prompt"; + expected = "subagentStart.additional_context"; } else { throw new Error(`unreviewed canonical global hook: ${key}`); } diff --git a/scripts/cursor/contract.ts b/scripts/cursor/contract.ts index 03a49671..15daf5a9 100644 --- a/scripts/cursor/contract.ts +++ b/scripts/cursor/contract.ts @@ -87,7 +87,7 @@ export const CURSOR_HOOK_DISPOSITIONS = Object.freeze({ "PreToolUse(Read)": "preToolUse.additional_context", "PostToolUse(Bash)": "postToolUse.additional_context", "PostToolUse(Write|Edit)": "postToolUse.additional_context", - SubagentStart: "prompt", + SubagentStart: "subagentStart.additional_context", "per-agent PreToolUse": "advisory", }); diff --git a/scripts/cursor/fixtures/cursor-3.17.8-hook-payloads.json b/scripts/cursor/fixtures/cursor-3.17.8-hook-payloads.json index fa82a4b2..64086d85 100644 --- a/scripts/cursor/fixtures/cursor-3.17.8-hook-payloads.json +++ b/scripts/cursor/fixtures/cursor-3.17.8-hook-payloads.json @@ -104,5 +104,25 @@ "workspace_roots": [ "/private/tmp/claude-501/-Users-someone-Projects-Example/00000000-1111-2222-3333-444444444444/scratchpad/cursor-hook-probe" ] + }, + "subagent-start": { + "conversation_id": "", + "cursor_version": "3.17.8", + "generation_id": "", + "hook_event_name": "subagentStart", + "is_parallel_worker": false, + "model": "Cursor Grok 4.6", + "parent_conversation_id": "", + "session_id": "", + "subagent_id": "", + "subagent_model": "cursor-grok-4.6-medium", + "subagent_type": "memory-auditor", + "task": "You are the memory-auditor. Run your audit and return your complete output.", + "tool_call_id": "", + "transcript_path": "", + "user_email": "", + "workspace_roots": [ + "" + ] } } diff --git a/scripts/cursor/hooks.test.ts b/scripts/cursor/hooks.test.ts index 75acb6fd..0d247bc2 100644 --- a/scripts/cursor/hooks.test.ts +++ b/scripts/cursor/hooks.test.ts @@ -94,6 +94,9 @@ test("renders the native Cursor hook manifest and non-executable runtime copies" beforeSubmitPrompt: [ { command: "python3 ./scripts/cursor-hook-adapter.py prompt-submit", timeout: 5 }, ], + subagentStart: [ + { command: "python3 ./scripts/cursor-hook-adapter.py subagent-start", timeout: 5 }, + ], preToolUse: [ { command: "python3 ./scripts/cursor-hook-adapter.py pretool-read", matcher: "Read", timeout: 5 }, ], @@ -109,6 +112,7 @@ test("renders the native Cursor hook manifest and non-executable runtime copies" "scripts/posttool-bash-hints.py", "scripts/pretool-crash-route.py", "scripts/project_detect.py", + "scripts/subagent-start.py", "scripts/swift-guardrails.py", "scripts/user-prompt-submit.py", ]); diff --git a/scripts/cursor/hooks.ts b/scripts/cursor/hooks.ts index 866dfb5c..78a7658c 100644 --- a/scripts/cursor/hooks.ts +++ b/scripts/cursor/hooks.ts @@ -23,6 +23,9 @@ const HOOKS_DOCUMENT = { beforeSubmitPrompt: [ { command: "python3 ./scripts/cursor-hook-adapter.py prompt-submit", timeout: 5 }, ], + subagentStart: [ + { command: "python3 ./scripts/cursor-hook-adapter.py subagent-start", timeout: 5 }, + ], preToolUse: [ { command: "python3 ./scripts/cursor-hook-adapter.py pretool-read", matcher: "Read", timeout: 5 }, ], @@ -38,7 +41,7 @@ function readFile(source: string): string { } export function renderCursorHooks(): VirtualFile[] { - const runtime = ["pretool-crash-route.py", "project_detect.py", "posttool-bash-hints.py", "swift-guardrails.py", "user-prompt-submit.py"].map((filename) => ({ + const runtime = ["pretool-crash-route.py", "project_detect.py", "posttool-bash-hints.py", "subagent-start.py", "swift-guardrails.py", "user-prompt-submit.py"].map((filename) => ({ path: `scripts/${filename}`, content: readFile(path.join(canonicalHooksDirectory, filename)), mode: 0o644 as const, diff --git a/scripts/cursor/real-payload.test.ts b/scripts/cursor/real-payload.test.ts index 66dd8d45..65032614 100644 --- a/scripts/cursor/real-payload.test.ts +++ b/scripts/cursor/real-payload.test.ts @@ -187,3 +187,41 @@ test("preToolUse(Read) never returns a permission field", () => { fs.rmSync(workspace, { recursive: true, force: true }); } }); + +test("subagentStart maps Cursor's subagent_type onto the canonical agent_type", () => { + // Captured from a live Cursor 3.17.8 delegation. The hook fires and delivers this + // payload even on a plan that then refuses to start the subagent. + const workspace = applePackage(); + try { + const payload = { ...FIXTURE["subagent-start"], workspace_roots: [workspace] }; + assert.equal(payload.subagent_type, "memory-auditor"); + assert.ok(!("agent_type" in payload), "Cursor names the field subagent_type"); + const { response, stderr } = invoke("subagent-start", payload); + assert.equal(stderr, ""); + assert.match( + (response as { additional_context?: string }).additional_context ?? "", + /skill/i, + "a named Axiom agent must receive skill awareness", + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("subagentStart stays silent for subagent types the router excludes", () => { + // Generic types like general-purpose are deliberately NOT excluded: a generic + // subagent working in an Apple project should still learn the skills exist. + const workspace = applePackage(); + try { + for (const subagent_type of ["statusline-setup", "beads:task-agent", "probe-noskills", ""]) { + const payload = { ...FIXTURE["subagent-start"], workspace_roots: [workspace], subagent_type }; + assert.deepEqual( + invoke("subagent-start", payload).response, + {}, + `subagent_type=${JSON.stringify(subagent_type)}`, + ); + } + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/scripts/cursor/runtime/cursor-hook-adapter.py b/scripts/cursor/runtime/cursor-hook-adapter.py index 77058bb4..6ecb3b7b 100644 --- a/scripts/cursor/runtime/cursor-hook-adapter.py +++ b/scripts/cursor/runtime/cursor-hook-adapter.py @@ -29,6 +29,8 @@ MAX_POST_WRITE_FILE_BYTES = 1024 * 1024 MIN_ROUTED_PROMPT_CHARS = 5 MAX_ROUTED_PROMPT_CHARS = 2000 MAX_ROUTED_CONTEXT_CHARS = 2048 +# Skill-awareness guidance is legitimately multi-paragraph. +MAX_SUBAGENT_CONTEXT_CHARS = 8192 # Cursor gives this hook 5 seconds. Leave 1.25 seconds for process-group teardown # and JSON emission if the canonical child reaches its internal deadline. CHILD_TIMEOUT_SECONDS = 3.75 @@ -268,6 +270,46 @@ def pretool_read(payload: Dict[str, Any]) -> Dict[str, str]: return {"additional_context": _cursor_crash_hint(context)} +def subagent_start(payload: Dict[str, Any]) -> Dict[str, str]: + """Port of the canonical SubagentStart skill awareness to Cursor's subagentStart. + + Cursor names the field `subagent_type`; the canonical hook reads `agent_type`. + This is additive to the per-agent Required Skills preamble in the generated agent + files: the preamble carries that agent's declared skills, this carries the general + skill-usage awareness the canonical hook injects, gated by agent type. + """ + subagent_type = payload.get("subagent_type") + if not isinstance(subagent_type, str) or not subagent_type: + return {} + if _has_control_characters(subagent_type): + return {} + child_output = run_child( + "subagent-start.py", + {"agent_type": subagent_type}, + cwd=_workspace_root(payload), + ) + if not child_output.strip(): + return {} + try: + response = json.loads(child_output) + except json.JSONDecodeError: + raise AdapterError("invalid child JSON") + if not isinstance(response, dict): + raise AdapterError("invalid child JSON") + specific = response.get("hookSpecificOutput") + if not isinstance(specific, dict): + return {} + context = specific.get("additionalContext") + if not isinstance(context, str): + return {} + context = context.strip() + if not context: + return {} + if len(context) > MAX_SUBAGENT_CONTEXT_CHARS or _has_unsafe_control_characters(context): + raise AdapterError("unexpected subagent context") + return {"additional_context": context} + + def prompt_submit(payload: Dict[str, Any]) -> Dict[str, str]: """Port of the canonical UserPromptSubmit router to Cursor's beforeSubmitPrompt. @@ -370,6 +412,14 @@ def post_shell(payload: Dict[str, Any]) -> Dict[str, str]: return {"additional_context": "\n".join(lines)} if lines else {} +def _has_unsafe_control_characters(value: str) -> bool: + """Control characters other than newline and tab. + + Multi-line guidance is legitimate for injected context; raw escapes and NULs are not. + """ + return any((ord(ch) < 32 or ord(ch) == 127) and ch not in "\n\t" for ch in value) + + def _has_control_characters(value: str) -> bool: return any(ord(character) < 32 or ord(character) == 127 for character in value) @@ -606,6 +656,8 @@ def dispatch(mode: str, payload: Dict[str, Any]) -> Dict[str, str]: return prompt_submit(payload) if mode == "pretool-read": return pretool_read(payload) + if mode == "subagent-start": + return subagent_start(payload) raise AdapterError("unknown mode") diff --git a/scripts/cursor/source.ts b/scripts/cursor/source.ts index 56c1bd79..72615890 100644 --- a/scripts/cursor/source.ts +++ b/scripts/cursor/source.ts @@ -32,6 +32,7 @@ const RUNTIME_FILES = [ "hooks/posttool-bash-hints.py", "hooks/pretool-crash-route.py", "hooks/project_detect.py", + "hooks/subagent-start.py", "hooks/swift-guardrails.py", "hooks/user-prompt-submit.py", ];