Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 6 additions & 34 deletions .agents/hooks/core/agentmemory-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@ import https from "node:https";
import { homedir } from "node:os";
import { basename, join } from "node:path";

// AgentMemory's published version line moved from 0.11/0.12 (original design
// target) to 0.9.x service builds; accept 0.9.x and the 0.1x.x range.
const SUPPORTED = /^0\.(9|1\d)\./;

function endpointUrl(): string | null {
if (process.env.OMA_NO_AGENTMEMORY === "1") return null;
if (process.env.AGENTMEMORY_URL) return process.env.AGENTMEMORY_URL;
Expand Down Expand Up @@ -106,36 +102,12 @@ export async function isAgentMemoryReachable(): Promise<boolean> {

try {
const response = await requestAgentMemory(url, "/agentmemory/health");
if (response.statusCode < 200 || response.statusCode >= 300) {
reachable = false;
return reachable;
}
const headerVersion = response.headers["x-agentmemory-version"];
const version = Array.isArray(headerVersion)
? headerVersion[0]
: headerVersion;
// Recent AgentMemory releases expose the version only in the health body,
// not the `x-agentmemory-version` header.
let isAgentMemory = false;
let bodyVersion: string | undefined;
try {
const parsed = JSON.parse(response.body) as {
service?: unknown;
status?: unknown;
version?: unknown;
};
isAgentMemory =
parsed.service === "agentmemory" ||
parsed.status === "healthy" ||
parsed.status === "ok";
if (typeof parsed.version === "string") bodyVersion = parsed.version;
} catch {
// Non-JSON body — fall back to the header check below.
}
const resolvedVersion = version ?? bodyVersion;
reachable =
isAgentMemory ||
(resolvedVersion !== undefined && SUPPORTED.test(resolvedVersion));
// Capability-based acceptance: any 2xx health response from the
// explicitly configured endpoint counts as reachable. Version pinning
// proved brittle (the published line already jumped from 0.11/0.12
// design targets to 0.9.x service builds), so payload shape and version
// are no longer gating.
reachable = response.statusCode >= 200 && response.statusCode < 300;
return reachable;
} catch {
reachable = false;
Expand Down
56 changes: 40 additions & 16 deletions .agents/hooks/core/hook-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import type { Vendor } from "./types.ts";
export function makePromptOutput(
vendor: Vendor,
additionalContext: string,
// Native hook event the context is injected for. Defaults to the prompt-submit
// event; the dispatch layer passes "SessionStart" for session-start injection
// (commandcode / cursor sessionStart) so the emitted hookSpecificOutput names
// the correct event. Standalone core-script callers use the default.
hookEventName: string = "UserPromptSubmit",
): string {
switch (vendor) {
case "antigravity":
Expand All @@ -19,33 +24,43 @@ export function makePromptOutput(
injectSteps: [{ ephemeralMessage: additionalContext }],
});
case "claude":
case "commandcode":
case "commandcode": {
// Official Claude Code docs (code.claude.com/docs/en/hooks) specify
// `hookSpecificOutput.additionalContext` — the top-level field is kept
// for back-compat with older builds that read it.
// commandcode (Command Code, commandcode.ai) mirrors the Claude hook
// dialect, but has NO prompt event (only PreToolUse/PostToolUse/Stop),
// so this branch never fires for it — kept for Vendor exhaustiveness.
return JSON.stringify({
// dialect. It has no prompt-submit event, but DOES inject context on
// SessionStart (additionalContext) — dispatch passes hookEventName
// "SessionStart" for that path.
const hookSpecificOutput: Record<string, unknown> = {
hookEventName,
additionalContext,
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext,
},
});
};
// Claude Code re-scans skill/command directories after SessionStart hooks
// complete when the output sets `reloadSkills` (docs: SessionStart
// hookSpecificOutput.reloadSkills). It is Claude-only and only meaningful
// for SessionStart; this builder is called solely when context was
// actually injected, so the "only when injecting" condition is inherent.
if (vendor === "claude" && hookEventName === "SessionStart") {
hookSpecificOutput.reloadSkills = true;
}
return JSON.stringify({ additionalContext, hookSpecificOutput });
}
case "codex":
return JSON.stringify({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
hookEventName,
additionalContext,
},
});
case "cursor":
// Cursor reads the top-level `additional_context` (sessionStart) /
// `additionalContext`; the hookSpecificOutput block is informational.
return JSON.stringify({
additionalContext,
additional_context: additionalContext,
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
hookEventName,
additionalContext,
},
});
Expand All @@ -70,7 +85,7 @@ export function makePromptOutput(
// Qwen Code fork uses hookSpecificOutput (same as Codex)
return JSON.stringify({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
hookEventName,
additionalContext,
},
});
Expand All @@ -82,18 +97,27 @@ export function makeBlockOutput(vendor: Vendor, reason: string): string {
case "claude":
case "codex":
case "commandcode":
case "cursor":
case "kiro":
case "qwen":
return JSON.stringify({ decision: "block", reason });
case "cursor":
// Cursor's `stop` hook ignores Claude-style `{decision:"block"}`. It
// re-enters the loop via `{followup_message}`, which is auto-submitted as
// the next turn (capped by the entry's loop_limit). Cursor's only
// block-producing chain is `stop` — its sole preToolUse handler
// (test-filter) never blocks, only mutates — so followup_message is
// always the correct dialect here.
return JSON.stringify({ followup_message: reason });
case "antigravity":
// agy Stop: `decision:"continue"` re-enters the loop (= block the stop);
// `reason` is injected as a system message. (Any other value allows stop.)
return JSON.stringify({ decision: "continue", reason });
case "pi":
// pi has no stop-blocking event (agent_end is notification-only), so
// persistent-mode never runs under pi. This shape mirrors pi's native
// tool_call block return for completeness/forward-compat.
// pi's bridge implements persistent-mode via agent_settled +
// pi.sendUserMessage: it runs the persistent-mode subprocess (which
// resolves to the Claude dialect `{decision:"block", reason}`) and also
// accepts this `{block:true, reason}` shape, re-submitting `reason` as the
// next turn so the workflow continues.
return JSON.stringify({ block: true, reason });
case "grok":
// Grok Stop hooks are generally advisory. Emit block decision + rich
Expand Down
Loading