Skip to content

Commit d790769

Browse files
author
codejunkie99
committed
review: address pi tool-result hook review findings
Cross-model review (Claude + Codex adversarial) flagged 4 issues that would either lose user data on Windows or silently degrade episodic memory quality. 1. install.ps1:157-159 — DATA LOSS on Windows re-install. `Remove-Item -LiteralPath $skillsDst -Recurse -Force` on PowerShell 5.1 (default Windows shell) traverses INTO a symlink target and deletes its contents before removing the link. A second `install.ps1 pi` run would wipe .agent/skills/. Detect ReparsePoint via Get-Item.Attributes BEFORE Remove-Item; use .NET Directory.Delete($path, false) on links so only the link is removed, never the target. 2. adapters/pi/memory-hook.ts — no subprocess timeout, hangs Pi. `await runHook(payload)` is awaited inside Pi's tool_result handler; a stuck Python child blocks Pi's event loop forever. Add 3s default timeout (overridable via $AGENT_HOOK_TIMEOUT_MS), kill the child on timeout, surface `timeout` as a separate result kind. 3. adapters/pi/memory-hook.ts — stderr was dropped, failures undiagnosable. Switch stdio to capture stderr (bounded to 4KB to avoid memory blowup on a wedged hook) and surface the first line in the failure notification. 4. .agent/harness/hooks/pi_post_tool.py — Pi sends tool_input with camelCase keys (filePath, oldString, newString), but the shared cc.* helpers (action_label, reflection, importance) expect Claude Code's snake_case keys. Without normalization, every Edit/Write logged by Pi degraded to "edit: ?" / "Edited ?" with empty detail. Add a Pi→canonical input key map applied in _normalize_input(). 5. .agent/harness/hooks/pi_post_tool.py — fail-open on malformed payload was logging bogus "Unknown success" entries (Codex's High #1). If Pi ever changes the event shape or sends invalid JSON, episodic memory got polluted with noise instead of a real signal. Add a _emit_malformed() path that records an explicit `hook:malformed_payload` failure entry with a 200-char excerpt of the offending payload — visible in AGENT_LEARNINGS.jsonl as a real error, not noise. Smoke-tested: - empty payload → `hook:malformed_payload | empty payload` - malformed JSON → `hook:malformed_payload | json decode error: ...` - Pi camelCase Edit (filePath/oldString/newString) → produces correct `edit: /tmp/x.txt` action label and `Edited /tmp/x.txt: replaced 'a' with 'b'` reflection (was: `edit: ?` / `Edited ?`) - well-formed bash success → unchanged behavior Codex's concurrent-write concern (Codex #3) and the rsync-style sync for .pi/skills (Codex #5) are NOT addressed here — they apply to pre-existing infrastructure (post_execution.py write semantics, pi's existing symlink path) and are scoped as separate follow-ups.
1 parent b64b80c commit d790769

3 files changed

Lines changed: 180 additions & 20 deletions

File tree

.agent/harness/hooks/pi_post_tool.py

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,55 @@
3333
"ls": "LS",
3434
"task": "Task",
3535
"todowrite": "TodoWrite",
36+
"todo_write": "TodoWrite",
3637
"webfetch": "WebFetch",
38+
"web_fetch": "WebFetch",
39+
}
40+
41+
# Pi tends to use camelCase for tool_input keys; the shared
42+
# claude_code_post_tool helpers (cc._action_label, cc._reflection,
43+
# cc._importance) expect the snake_case keys Claude Code sends.
44+
# Normalize a known set so action labels and reflections come out
45+
# meaningful instead of degrading to "edit: ?" / "Edited ?".
46+
_PI_INPUT_KEY_MAP = {
47+
"filePath": "file_path",
48+
"filepath": "file_path",
49+
"path": "file_path",
50+
"oldString": "old_string",
51+
"oldstring": "old_string",
52+
"newString": "new_string",
53+
"newstring": "new_string",
54+
"content": "content",
55+
"command": "command",
56+
"todos": "todos",
57+
"todo": "todos",
58+
"url": "url",
59+
"pattern": "pattern",
60+
"query": "query",
3761
}
3862

3963

4064
def _tool_name(name: str) -> str:
4165
if not isinstance(name, str):
4266
return "Unknown"
4367
lowered = name.strip().lower()
44-
return _PI_TO_CANONICAL.get(lowered, name[:1].upper() + name[1:])
68+
if lowered in _PI_TO_CANONICAL:
69+
return _PI_TO_CANONICAL[lowered]
70+
if "_" in name:
71+
return "".join(part[:1].upper() + part[1:] for part in name.split("_") if part)
72+
return name[:1].upper() + name[1:]
73+
74+
75+
def _normalize_input(raw) -> dict:
76+
"""Map Pi tool_input keys to the snake_case shape cc.* helpers expect."""
77+
if not isinstance(raw, dict):
78+
if raw is None:
79+
return {}
80+
return {"raw": str(raw)}
81+
out: dict = {}
82+
for k, v in raw.items():
83+
out[_PI_INPUT_KEY_MAP.get(k, k)] = v
84+
return out
4585

4686

4787
def _extract_text(content) -> str:
@@ -97,17 +137,51 @@ def _normalize_response(event: dict) -> dict:
97137
return resp
98138

99139

140+
def _emit_malformed(reason: str, raw_excerpt: str) -> None:
141+
"""Record an explicit failure entry instead of silently logging a bogus
142+
'Unknown success'. If Pi changes payload shape or sends malformed
143+
JSON, this surfaces in AGENT_LEARNINGS.jsonl as a real signal.
144+
"""
145+
excerpt = raw_excerpt[:200] if isinstance(raw_excerpt, str) else ""
146+
on_failure(
147+
skill_name="pi",
148+
action="hook:malformed_payload",
149+
error=f"pi tool_result payload malformed: {reason}",
150+
context=excerpt,
151+
confidence=0.95,
152+
importance="medium",
153+
pain_score=2,
154+
)
155+
156+
100157
def main() -> None:
158+
raw = ""
101159
try:
102160
raw = sys.stdin.read()
103-
payload = json.loads(raw) if raw.strip() else {}
104-
except (json.JSONDecodeError, OSError):
105-
payload = {}
161+
except OSError as e:
162+
_emit_malformed(f"stdin read failed: {e}", "")
163+
return
164+
165+
if not raw or not raw.strip():
166+
_emit_malformed("empty payload", "")
167+
return
168+
169+
try:
170+
payload = json.loads(raw)
171+
except json.JSONDecodeError as e:
172+
_emit_malformed(f"json decode error: {e.msg}", raw)
173+
return
174+
175+
if not isinstance(payload, dict):
176+
_emit_malformed(f"payload is {type(payload).__name__}, expected object", raw)
177+
return
178+
179+
if "tool_name" not in payload:
180+
_emit_malformed("missing tool_name", raw)
181+
return
106182

107183
tool_name = _tool_name(payload.get("tool_name") or "Unknown")
108-
tool_input = payload.get("tool_input") or {}
109-
if not isinstance(tool_input, dict):
110-
tool_input = {"raw": str(tool_input)}
184+
tool_input = _normalize_input(payload.get("tool_input"))
111185
tool_response = _normalize_response(payload)
112186

113187
success = cc._is_success(tool_name, tool_input, tool_response)

adapters/pi/memory-hook.ts

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,31 @@ const HOOK_SCRIPT = path.join(
1414
"pi_post_tool.py",
1515
);
1616

17+
// Timeout for the Python child. If the hook hangs (bad import, stuck I/O),
18+
// Pi's tool_result handler stays blocked because the extension awaits
19+
// runHook(). Override via $AGENT_HOOK_TIMEOUT_MS for slow machines.
20+
const HOOK_TIMEOUT_MS = (() => {
21+
const raw = process.env.AGENT_HOOK_TIMEOUT_MS?.trim();
22+
const n = raw ? Number.parseInt(raw, 10) : NaN;
23+
return Number.isFinite(n) && n > 0 ? n : 3000;
24+
})();
25+
1726
let warnedMissingHook = false;
1827
let warnedMissingPython = false;
1928
let warnedHookFailure = false;
29+
let warnedHookTimeout = false;
2030

2131
type PythonCandidate = {
2232
command: string;
2333
args: string[];
2434
};
2535

36+
type HookResult =
37+
| { kind: "ok" }
38+
| { kind: "spawn-error" }
39+
| { kind: "hook-failure"; stderr: string; exitCode: number | null }
40+
| { kind: "timeout" };
41+
2642
function pythonCandidates(): PythonCandidate[] {
2743
const envPy = process.env.AGENT_PYTHON?.trim();
2844
const out: PythonCandidate[] = [];
@@ -36,32 +52,77 @@ function pythonCandidates(): PythonCandidate[] {
3652
function tryRun(
3753
candidate: PythonCandidate,
3854
payload: Record<string, unknown>,
39-
): Promise<"ok" | "spawn-error" | "hook-failure"> {
55+
): Promise<HookResult> {
4056
return new Promise((resolve) => {
57+
let settled = false;
58+
const settle = (r: HookResult) => {
59+
if (settled) return;
60+
settled = true;
61+
resolve(r);
62+
};
63+
64+
let stderrBuf = "";
4165
const child = spawn(
4266
candidate.command,
4367
[...candidate.args, HOOK_SCRIPT],
4468
{
4569
cwd: PROJECT_ROOT,
46-
stdio: ["pipe", "ignore", "ignore"],
70+
// Capture stderr so a "hook-failure" notification can include
71+
// the actual error instead of being undiagnosable.
72+
stdio: ["pipe", "ignore", "pipe"],
4773
},
4874
);
49-
child.on("error", () => resolve("spawn-error"));
75+
76+
const timer = setTimeout(() => {
77+
try { child.kill("SIGKILL"); } catch { /* already dead */ }
78+
settle({ kind: "timeout" });
79+
}, HOOK_TIMEOUT_MS);
80+
81+
child.on("error", () => {
82+
clearTimeout(timer);
83+
settle({ kind: "spawn-error" });
84+
});
5085
child.on("spawn", () => {
51-
child.stdin.end(JSON.stringify(payload));
86+
try {
87+
child.stdin.end(JSON.stringify(payload));
88+
} catch {
89+
// stdin closed before we could write — handled by close/error
90+
}
5291
});
92+
if (child.stderr) {
93+
child.stderr.setEncoding("utf8");
94+
child.stderr.on("data", (chunk: string) => {
95+
// bound stderr buffer to avoid memory blowup on a wedged hook
96+
if (stderrBuf.length < 4096) stderrBuf += chunk;
97+
});
98+
}
5399
child.on("close", (code) => {
54-
resolve(code === 0 ? "ok" : "hook-failure");
100+
clearTimeout(timer);
101+
if (code === 0) {
102+
settle({ kind: "ok" });
103+
} else {
104+
settle({ kind: "hook-failure", stderr: stderrBuf.trim(), exitCode: code });
105+
}
55106
});
56107
});
57108
}
58109

59-
async function runHook(payload: Record<string, unknown>) {
110+
async function runHook(
111+
payload: Record<string, unknown>,
112+
): Promise<
113+
| "ok"
114+
| "missing-hook"
115+
| "missing-python"
116+
| "timeout"
117+
| { kind: "hook-failure"; stderr: string; exitCode: number | null }
118+
> {
60119
if (!existsSync(HOOK_SCRIPT)) return "missing-hook";
61120
for (const candidate of pythonCandidates()) {
62121
const result = await tryRun(candidate, payload);
63-
if (result === "ok") return "ok";
64-
if (result === "hook-failure") return "hook-failure";
122+
if (result.kind === "ok") return "ok";
123+
if (result.kind === "timeout") return "timeout";
124+
if (result.kind === "hook-failure") return result;
125+
// spawn-error → try the next python candidate
65126
}
66127
return "missing-python";
67128
}
@@ -90,18 +151,30 @@ export default function (pi: ExtensionAPI) {
90151
"agentic-stack pi memory hook: python3/python not found; automatic episodic logging disabled.",
91152
"warning",
92153
);
93-
} else if (result === "hook-failure" && !warnedHookFailure) {
154+
} else if (result === "timeout" && !warnedHookTimeout) {
155+
warnedHookTimeout = true;
156+
ctx.ui.notify(
157+
`agentic-stack pi memory hook timed out (>${HOOK_TIMEOUT_MS}ms); subsequent calls may be skipped. Override with $AGENT_HOOK_TIMEOUT_MS.`,
158+
"warning",
159+
);
160+
} else if (
161+
typeof result === "object" &&
162+
result.kind === "hook-failure" &&
163+
!warnedHookFailure
164+
) {
94165
warnedHookFailure = true;
166+
// Surface the first line of stderr so the failure is diagnosable.
167+
const firstLine = result.stderr.split(/\r?\n/, 1)[0] || `(exit ${result.exitCode})`;
95168
ctx.ui.notify(
96-
"agentic-stack pi memory hook failed; continuing without automatic episodic logging.",
169+
`agentic-stack pi memory hook failed: ${firstLine}`,
97170
"warning",
98171
);
99172
}
100173
} catch {
101174
if (!warnedHookFailure) {
102175
warnedHookFailure = true;
103176
ctx.ui.notify(
104-
"agentic-stack pi memory hook failed; continuing without automatic episodic logging.",
177+
"agentic-stack pi memory hook errored unexpectedly; continuing without automatic episodic logging.",
105178
"warning",
106179
);
107180
}

install.ps1

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,21 @@ switch ($Adapter) {
154154

155155
$skillsSrc = Join-Path $TargetAgent 'skills'
156156
$skillsDst = Join-Path $piDir 'skills'
157-
if (Test-Path $skillsDst) {
158-
Remove-Item -LiteralPath $skillsDst -Recurse -Force
157+
158+
# CRITICAL: detect symlink BEFORE Remove-Item. On PowerShell 5.1
159+
# (Windows default), `Remove-Item -Recurse -Force` on a symlink
160+
# traverses INTO the target and deletes its contents. Re-running
161+
# the installer would silently wipe .agent/skills via the link.
162+
# Use IsLink detection + .NET Delete (link only, not target).
163+
$skillsDstItem = Get-Item -LiteralPath $skillsDst -Force -ErrorAction SilentlyContinue
164+
if ($skillsDstItem) {
165+
$isLink = ($skillsDstItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq [System.IO.FileAttributes]::ReparsePoint
166+
if ($isLink) {
167+
try { [System.IO.Directory]::Delete($skillsDst, $false) }
168+
catch { [System.IO.File]::Delete($skillsDst) }
169+
} else {
170+
Remove-Item -LiteralPath $skillsDst -Recurse -Force
171+
}
159172
}
160173
try {
161174
New-Item -ItemType SymbolicLink -Path $skillsDst -Target $skillsSrc -ErrorAction Stop | Out-Null

0 commit comments

Comments
 (0)