Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
{
"name": "agent-plugin-playground",
"displayName": "Agent Plugin Playground",
"description": "Bun-powered native plugin playground for one active experiment at a time",
"description": "Bun-powered Agent Attention approval gates in the native plugin playground",
"author": {
"name": "My Agent Dojo"
},
Expand Down
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,8 @@ describe('Agent Attention Codex Stop guard', () => {
test('rejects missing and wrong-typed correlation fields', () => {
expect(isAgentAttentionStopInput(null)).toBe(false)
expect(isAgentAttentionStopInput([])).toBe(false)
expect(isAgentAttentionStopInput({ cwd: '', session_id: THREAD_ID })).toBe(
false,
)
expect(isAgentAttentionStopInput({ session_id: THREAD_ID })).toBe(true)
expect(isAgentAttentionStopInput({ cwd: 42, session_id: THREAD_ID })).toBe(false)
expect(isAgentAttentionStopInput({ cwd: '/tmp/repo' })).toBe(false)
expect(
isAgentAttentionStopInput({
Expand Down
100 changes: 16 additions & 84 deletions experiments/agent-attention/hooks/agent-attention-codex-stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,23 @@

import { join } from 'node:path'

import {
handleAgentAttentionStop as handleInstalledStop,
invalidAgentAttentionStopInput,
isAgentAttentionStopInput,
runAgentAttentionStop as runInstalledStop,
type AgentAttentionStopCheck,
type AgentAttentionStopInput,
type AgentAttentionStopOutput,
type AgentAttentionStopRuntime,
} from '../../../packages/agent-attention/src/stop'

export { isAgentAttentionStopInput }

/** Experiment-local Codex hook command kept aligned with its fixture. */
export const AGENT_ATTENTION_STOP_HOOK_COMMAND =
'bun "$(git rev-parse --show-toplevel)/experiments/agent-attention/hooks/agent-attention-codex-stop.ts"'

/** Stable Stop fields used to correlate one exact task with owner state. */
export interface AgentAttentionStopInput {
cwd: string
session_id: string
stop_hook_active?: boolean
}

/** Minimal owner result consumed by the hook adapter. */
export interface AgentAttentionStopCheck {
hook_action: 'allow' | 'continue'
reason?: string
}

/** Injectable owner seam for public-hook tests. */
export interface AgentAttentionStopRuntime {
checkStop: (threadId: string) => Promise<AgentAttentionStopCheck>
}

/** Codex Stop output that either permits stop or requests one continuation. */
export type AgentAttentionStopOutput =
| { continue: true; suppressOutput: true }
| { decision: 'block'; reason: string }

const INVALID_STOP_INPUT: AgentAttentionStopOutput = {
decision: 'block',
reason:
'Agent Attention could not correlate this Stop event to structured owner state. Repair the hook payload contract before stopping.',
}

/**
* Validate only the stable task-correlation fields needed by the owner.
*
* @param value - Untrusted Codex hook payload
* @returns True when the hook can correlate one exact task
*
* @example
* ```ts
* isAgentAttentionStopInput({ cwd: '/tmp/repo', session_id: crypto.randomUUID() })
* ```
*/
export function isAgentAttentionStopInput(
value: unknown,
): value is AgentAttentionStopInput {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const input = value as Record<string, unknown>
if (typeof input.cwd !== 'string' || input.cwd.trim() === '') return false
if (typeof input.session_id !== 'string' || input.session_id.trim() === '') {
return false
}
if (
input.stop_hook_active !== undefined &&
typeof input.stop_hook_active !== 'boolean'
) {
return false
}
return true
}

/**
* Enforce explicit owner state without reading assistant prose or transcripts.
*
Expand All @@ -81,19 +36,7 @@ export async function handleAgentAttentionStop(
input: AgentAttentionStopInput,
runtime: AgentAttentionStopRuntime = createDefaultRuntime(),
): Promise<AgentAttentionStopOutput> {
if (input.stop_hook_active) {
return { continue: true, suppressOutput: true }
}
const check = await runtime.checkStop(input.session_id)
if (check.hook_action === 'continue') {
return {
decision: 'block',
reason:
check.reason ??
'Agent Attention owner state requires an actionable repair before stopping.',
}
}
return { continue: true, suppressOutput: true }
return handleInstalledStop(input, runtime)
}

/**
Expand All @@ -107,18 +50,7 @@ export async function runAgentAttentionStop(
input: unknown,
runtime: AgentAttentionStopRuntime = createDefaultRuntime(),
): Promise<AgentAttentionStopOutput> {
if (!isAgentAttentionStopInput(input)) {
return INVALID_STOP_INPUT
}
try {
return await handleAgentAttentionStop(input, runtime)
} catch (error) {
const detail = error instanceof Error ? error.message : 'unknown error'
return {
decision: 'block',
reason: `Agent Attention could not verify structured owner state. Repair the owner check before stopping: ${detail}`,
}
}
return runInstalledStop(input, runtime)
}

function createDefaultRuntime(): AgentAttentionStopRuntime {
Expand Down Expand Up @@ -161,6 +93,6 @@ if (import.meta.main) {
const output = await runAgentAttentionStop(parsed)
process.stdout.write(`${JSON.stringify(output)}\n`)
} catch {
process.stdout.write(`${JSON.stringify(INVALID_STOP_INPUT)}\n`)
process.stdout.write(`${JSON.stringify(invalidAgentAttentionStopInput())}\n`)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import math
import os
import plistlib
import stat
import subprocess
import sys
import time
Expand All @@ -31,6 +32,7 @@
{"name": "check-stop", "summary": "Check structured owner state before a task stops."},
)
MANAGED_URL_PREFIX = "remindctl URL (managed): "
REMINDCTL_COMMAND_TIMEOUT_SECONDS = 30


class ContractError(Exception):
Expand Down Expand Up @@ -162,30 +164,31 @@ def acquire_request_lock(
return descriptor


def release_request_lock(path: Path, descriptor: int) -> None:
"""Remove only the path still backed by the held request lock."""
def release_request_lock(_path: Path, descriptor: int) -> None:
"""Release one persistent request-lock inode."""
try:
try:
path_stat = os.stat(path, follow_symlinks=False)
except FileNotFoundError:
path_stat = None
descriptor_stat = os.fstat(descriptor)
if path_stat and (
path_stat.st_dev == descriptor_stat.st_dev
and path_stat.st_ino == descriptor_stat.st_ino
):
path.unlink()
finally:
fcntl.flock(descriptor, fcntl.LOCK_UN)
finally:
os.close(descriptor)


def append_audit(path: Path, value: Any) -> None:
"""Append one private JSON audit event."""
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
with path.open("a", encoding="utf-8") as handle:
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags, 0o600)
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
raise ContractError("audit path must be one singly linked regular file")
os.fchmod(descriptor, 0o600)
except BaseException:
os.close(descriptor)
raise
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with os.fdopen(descriptor, "a", encoding="utf-8") as handle:
handle.write(json.dumps(value, sort_keys=True) + "\n")
os.chmod(path, 0o600)


def run_json(command: list[str], *, timeout_seconds: float | None = None) -> Any:
Expand Down Expand Up @@ -622,7 +625,8 @@ def _submit_approval(args: argparse.Namespace) -> dict[str, Any]:
notification_at,
"--json",
"--no-input",
]
],
timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
except OSError as error:
request_claim_path.unlink(missing_ok=True)
Expand Down Expand Up @@ -673,7 +677,9 @@ def _submit_approval(args: argparse.Namespace) -> dict[str, Any]:
)
raise
try:
created_inventory = read_inventory(config)
created_inventory = read_inventory(
config, timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS
)
except (ContractError, json.JSONDecodeError, OSError, subprocess.SubprocessError) as error:
write_json(
path,
Expand Down Expand Up @@ -989,7 +995,8 @@ def read_exact_completed_reminder(reminder_id: str, list_id: str) -> dict[str, A
list_id,
"--json",
"--no-input",
]
],
timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS,
)
inventory = require_reminder_inventory(
inventory, document_name="completed reminder inventory"
Expand Down Expand Up @@ -1136,7 +1143,8 @@ def record_outcome(args: argparse.Namespace) -> dict[str, Any]:
updated_notes,
"--json",
"--no-input",
]
],
timeout_seconds=REMINDCTL_COMMAND_TIMEOUT_SECONDS,
)
except OSError:
claim_path.unlink(missing_ok=True)
Expand Down Expand Up @@ -1648,7 +1656,8 @@ def main() -> int:
json.dumps(
base_result(
"error",
changed="unknown",
changed=False,
change_uncertain=True,
retry_safe=False,
error_category="contract_or_runtime",
next_safe_action="inspect current state before retry",
Expand Down
Loading