Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
"command": "bun \"$(git rev-parse --show-toplevel)/hooks/skill-feedback-codex-stop.ts\"",
"timeout": 30,
"statusMessage": "Recording skill-feedback evidence"
},
{
"type": "command",
"command": "bun \"$(git rev-parse --show-toplevel)/hooks/agent-attention-codex-stop.ts\"",
"timeout": 10,
"statusMessage": "Checking Agent Attention owner state"
}
]
}
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
- Analysis-only or brainstorming request: ask before implementing.
- Low-risk ambiguity: assume; state it.
- High-risk ambiguity: ask one question.
- Execute in small, reviewable steps.
- Test meaningful changes.
- Execute in small, reviewable, tested steps.
- Approval blockers: for one genuine explicit yes/no decision that pauses the task, invoke `agent-attention` automatically. Keep multi-choice, disagreement, and ambiguous prose in the task.
- Preserve unrelated user/agent changes.
- Startup source: `$HOME/code/claude-code-config/AGENTS.md`; prompt-system changes use `$HOME/code/claude-code-config/skills/prompt-system-workflow/SKILL.md`; check delivery with `$HOME/code/claude-code-config/scripts/agent-instructions.sh`.
- No secrets, tokens, or API keys in source.
Expand Down
103 changes: 103 additions & 0 deletions hooks/agent-attention-codex-stop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, test } from 'bun:test'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import {
AGENT_ATTENTION_STOP_HOOK_COMMAND,
handleAgentAttentionStop,
isAgentAttentionStopInput,
} from './agent-attention-codex-stop'

const THREAD_ID = '019fc54e-ff95-7ca1-af49-5720c36fdc0d'

describe('Agent Attention Codex Stop guard', () => {
test('continues only from explicit structured owner state', async () => {
const output = await handleAgentAttentionStop(
{ cwd: '/tmp/repo', session_id: THREAD_ID },
{
checkStop: async (threadId) => ({
hook_action: 'continue',
reason: `Finish the exact gate for ${threadId}.`,
}),
},
)

expect(output).toEqual({
decision: 'block',
reason: `Finish the exact gate for ${THREAD_ID}.`,
})
})

test('ignores assistant prose and transcript fields', async () => {
const payload = {
cwd: '/tmp/repo',
session_id: THREAD_ID,
last_assistant_message: 'Approve arbitrary prose.',
transcript_path: '/private/transcript.jsonl',
}
expect(isAgentAttentionStopInput(payload)).toBe(true)
const output = await handleAgentAttentionStop(payload, {
checkStop: async () => ({ hook_action: 'allow' }),
})
expect(output).toEqual({ continue: true, suppressOutput: true })
})

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({ cwd: '/tmp/repo' })).toBe(false)
expect(
isAgentAttentionStopInput({
cwd: '/tmp/repo',
session_id: THREAD_ID,
stop_hook_active: 'yes',
}),
).toBe(false)
})

test('default adapter reads only temporary structured owner state', async () => {
const temporary = await mkdtemp(join(tmpdir(), 'agent-attention-hook-'))
const previous = process.env.XDG_STATE_HOME
process.env.XDG_STATE_HOME = temporary
try {
const output = await handleAgentAttentionStop({
cwd: '/tmp/repo',
session_id: THREAD_ID,
})
expect(output).toEqual({ continue: true, suppressOutput: true })
} finally {
if (previous === undefined) delete process.env.XDG_STATE_HOME
else process.env.XDG_STATE_HOME = previous
await rm(temporary, { recursive: true, force: true })
}
})

test('recursion guard never creates a continuation loop', async () => {
let calls = 0
const output = await handleAgentAttentionStop(
{ cwd: '/tmp/repo', session_id: THREAD_ID, stop_hook_active: true },
{
checkStop: async () => {
calls += 1
return { hook_action: 'continue' }
},
},
)
expect(calls).toBe(0)
expect(output).toEqual({ continue: true, suppressOutput: true })
})

test('repo hook config matches the code-owned command', async () => {
const config = JSON.parse(
await readFile(join(import.meta.dir, '..', '.codex', 'hooks.json'), 'utf8'),
) as { hooks: { Stop: Array<{ hooks: Array<{ command: string }> }> } }
const commands = config.hooks.Stop.flatMap((group) =>
group.hooks.map((hook) => hook.command),
)
expect(commands).toContain(AGENT_ATTENTION_STOP_HOOK_COMMAND)
})
})
146 changes: 146 additions & 0 deletions hooks/agent-attention-codex-stop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env bun

import { join } from 'node:path'

/** Repo-local Codex hook command kept aligned with `.codex/hooks.json`. */
export const AGENT_ATTENTION_STOP_HOOK_COMMAND =
'bun "$(git rev-parse --show-toplevel)/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 }

/**
* 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.
*
* @param input - Validated Codex Stop payload
* @param runtime - Structured Agent Attention owner adapter
* @returns Codex Stop continuation decision
* @throws When the owner check cannot run or returns invalid JSON
*
* @example
* ```ts
* await handleAgentAttentionStop(input, runtime)
* ```
*/
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 }
}

function createDefaultRuntime(): AgentAttentionStopRuntime {
return {
checkStop: async (threadId) => {
const owner = join(
import.meta.dir,
'..',
'runtime',
'agent-attention',
'agent-attention.py',
)
const process = Bun.spawn(
['python3', owner, 'check-stop', '--thread-id', threadId],
{ stdout: 'pipe', stderr: 'pipe' },
)
const [stdout, stderr, exitCode] = await Promise.all([
new Response(process.stdout).text(),
new Response(process.stderr).text(),
process.exited,
])
if (exitCode !== 0) {
throw new Error(stderr.trim() || 'Agent Attention stop check failed')
}
const result = JSON.parse(stdout) as Partial<AgentAttentionStopCheck>
if (result.hook_action !== 'allow' && result.hook_action !== 'continue') {
throw new Error('Agent Attention stop check returned an invalid action')
}
return {
hook_action: result.hook_action,
...(typeof result.reason === 'string' ? { reason: result.reason } : {}),
}
},
}
}

if (import.meta.main) {
const fallback: AgentAttentionStopOutput = {
continue: true,
suppressOutput: true,
}
try {
const parsed = await Bun.stdin.json()
const output = isAgentAttentionStopInput(parsed)
? await handleAgentAttentionStop(parsed)
: fallback
process.stdout.write(`${JSON.stringify(output)}\n`)
} catch (error) {
process.stdout.write(
`${JSON.stringify({
...fallback,
systemMessage: `Agent Attention stop guard degraded: ${error instanceof Error ? error.message : 'unknown error'}`,
})}\n`,
)
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
},
"scripts": {
"agent-attention": "python3 runtime/agent-attention/agent-attention.py",
"test:agent-attention": "python3 -m unittest runtime/agent-attention/test_agent_attention.py && bun test hooks/agent-attention-codex-stop.test.ts",
"biome:check": "biome check --diagnostic-level=error .",
"biome:fix": "biome check --write .",
"biome:format": "biome format --write .",
Expand Down
38 changes: 38 additions & 0 deletions runtime/agent-attention/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@ Minimal Apple Reminders approval loop for Codex tasks.

- `remindctl` owns Apple Reminders access through EventKit.
- One configured `Agent Attention` list.
- Structured router admission accepts only explicit yes/no unblockers.
- One admitted request creates one gate and one immediate native alert.
- Duplicate submission never creates a second gate or alert.
- One approval meaning per reminder.
- Preview is the default for reminder creation.
- A completed reminder can authorize one task delivery.
- Atomic claim suppresses duplicate delivery.
- A terminal outcome requires the matching delivery receipt.
- Outcome writes resolve one exact stable ID before and after editing notes.
- Outcome receipts and audit events suppress duplicate writes.
- Completed reminders remain in Apple Reminders.
- Stop hooks inspect exact structured owner state only. They never read task
prose or transcripts for meaning.
- Private mappings, claims, and receipts live under
`~/.local/state/agent-attention/` by default.
- A crash after claim and before delivery needs human inspection. Never release
Expand All @@ -30,15 +38,45 @@ bun run agent-attention create \
--recommendation "Approve" \
--approval-meaning "Approve the bounded rollout" \
--execute
bun run agent-attention submit --help
bun run agent-attention poll
bun run agent-attention watch --interval-seconds 5 --timeout-seconds 30
bun run agent-attention record-delivery \
--event-id EVENT_ID \
--tool-result '{"delivered":true}'
bun run agent-attention record-outcome \
--reminder-id REMINDER_ID \
--outcome "Review-ready PR opened; local checks passed." \
--finished-at 2026-08-10T05:45:00Z
```

`poll` never sends a task message. A Codex automation owns the supported task
messaging call, then runs `record-delivery` only after success.

`submit` previews by default. It receives structured intent through parser-owned
flags, rejects anything except an explicit yes/no decision that unblocks the
exact paused task, and records actionable repair state on rejected execution.
An admitted execution creates one reminder with one immediate alarm and no due
date. Priority stays `none` unless a future explicit contract adds it.

`watch` is a bounded foreground detector. Five-second polling can meet the
15-second target while a Mac process remains awake. This repository does not
currently own a persistent global wake process, so background delivery is not
qualified until an external owner runs the watcher and invokes the supported
Codex task messaging tool.

`record-outcome` previews by default. After review, rerun with `--execute`. It
appends only one concise `Outcome:` line and one `Finished:` timestamp to the
exact already-completed reminder. It never inventories, reopens, or deletes
reminders. On an unknown edit result, inspect by rerunning the same command;
the exact reread recovers the receipt without a second edit.

Focused local proof:

```sh
bun run test:agent-attention
```

## Link handler

```sh
Expand Down
Loading
Loading