mirror of
https://github.com/CharlesWiltgen/Axiom.git
synced 2026-09-20 19:58:20 +08:00
feat(cursor): port subagent skill awareness to subagentStart
The third and last capability the ledger recorded as downgraded, replaced by a static preamble in each agent file. Cursor's subagentStart response accepts additional_context and its query carries subagent_type, which is the only field the canonical hook reads, so the port is a field rename. This is additive, not a replacement. The generated preamble carries an agent's own declared skills; the hook carries the general skill-awareness text gated by agent type. Both now ship. Verified as far as a free Cursor plan allows: the hook registers, fires, and delivers a payload carrying subagent_type, task, and subagent_model — confirmed against a live Cursor 3.17.8 delegation, which fires subagentStart three times before refusing to start the subagent with "Named models unavailable. Free plans can only use Auto." What a free plan cannot show is whether the returned context reaches a subagent that never starts. The docs and the submission matrix say so rather than implying full verification. If delivery does not work, the preamble still carries the declared skills, so the failure mode is the status quo rather than a regression. The context guard needed widening: skill awareness is legitimately multi-paragraph, where the router and crash hints are single-line. Newline and tab are now permitted, every other control character is still rejected, and the bound is separate at 8 KiB.
This commit is contained in:
@@ -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_ |
|
||||
|
||||
Generated
+6
@@ -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",
|
||||
|
||||
+2
-2
@@ -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
|
||||
},
|
||||
|
||||
+13
-8
@@ -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": {
|
||||
|
||||
+52
@@ -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")
|
||||
|
||||
|
||||
|
||||
Generated
+136
@@ -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))
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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": "<redacted-conversation_id>",
|
||||
"cursor_version": "3.17.8",
|
||||
"generation_id": "<redacted-generation_id>",
|
||||
"hook_event_name": "subagentStart",
|
||||
"is_parallel_worker": false,
|
||||
"model": "Cursor Grok 4.6",
|
||||
"parent_conversation_id": "<redacted-parent_conversation_id>",
|
||||
"session_id": "<redacted-session_id>",
|
||||
"subagent_id": "<redacted-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": "<redacted-tool_call_id>",
|
||||
"transcript_path": "<redacted-transcript_path>",
|
||||
"user_email": "<redacted-user_email>",
|
||||
"workspace_roots": [
|
||||
"<workspace>"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user