@meistrari/agent-core 0.2.0-experimental.1 → 0.2.0-experimental.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/docs/consumer-capability-matrix.md +374 -0
- package/package.json +17 -10
- package/scripts/generate-codex-protocol.ts +711 -0
- package/src/agents/agent-operation.ts +8 -0
- package/src/agents/agent-provider.ts +3 -0
- package/src/agents/agent-run.ts +14 -2
- package/src/agents/agent-runtime-error.ts +28 -0
- package/src/agents/agent-session-events.ts +33 -0
- package/src/agents/agent-tool-runner.ts +12 -11
- package/src/agents/agent-tool.ts +12 -4
- package/src/agents/author-context.ts +1 -1
- package/src/agents/claude/claude-auth.ts +40 -0
- package/src/agents/claude/claude-browser-turn-metadata.ts +70 -0
- package/src/agents/claude/claude-code-process.ts +98 -0
- package/src/agents/claude/claude-command-mapper.ts +63 -3
- package/src/agents/claude/claude-desktop.ts +55 -0
- package/src/agents/claude/claude-event-mapper.ts +868 -135
- package/src/agents/claude/claude-file-change.ts +128 -0
- package/src/agents/claude/claude-provider.ts +356 -30
- package/src/agents/claude/claude-run.ts +898 -130
- package/src/agents/claude/claude-selected-repository-instructions.ts +91 -0
- package/src/agents/claude/claude-skill-catalog.ts +75 -0
- package/src/agents/claude/claude-steered-notifications.ts +180 -0
- package/src/agents/claude/claude-tool-mapper.ts +81 -2
- package/src/agents/claude/claude-workspace-skills.ts +87 -0
- package/src/agents/claude/index.ts +2 -0
- package/src/agents/codex/codex-auth-mapper.ts +27 -0
- package/src/agents/codex/codex-auth.ts +19 -0
- package/src/agents/codex/codex-browser-auth-broker.ts +320 -0
- package/src/agents/codex/codex-browser-auth-status.ts +55 -0
- package/src/agents/codex/codex-command-mapper.ts +38 -3
- package/src/agents/codex/codex-event-mapper.ts +384 -67
- package/src/agents/codex/codex-json-rpc-client.ts +66 -21
- package/src/agents/codex/codex-provider.ts +118 -236
- package/src/agents/codex/codex-run-auth.ts +113 -0
- package/src/agents/codex/codex-run.ts +423 -53
- package/src/agents/codex/codex-tool-mapper.ts +2 -2
- package/src/agents/codex/codex.errors.ts +8 -0
- package/src/agents/codex/generated/meta.gen.ts +144 -6
- package/src/agents/codex/generated/namespaces.gen.ts +92 -3
- package/src/agents/codex/generated/schema.gen.ts +24698 -15638
- package/src/agents/codex/lazy-codex-run-auth.ts +47 -0
- package/src/agents/index.ts +5 -0
- package/src/agents/input-attachment-preparation.ts +13 -0
- package/src/agents/mcp-tool-content.ts +82 -0
- package/src/agents/model-rebinding-run.ts +14 -3
- package/src/agents/selected-repository-instructions.ts +202 -0
- package/src/agents/session-context.ts +41 -0
- package/src/agents/subscription-token-refresh.ts +73 -0
- package/src/agents/user-input-request.ts +19 -20
- package/src/errors/index.ts +1 -1
- package/src/errors/validation-error.ts +6 -0
- package/src/protocol/agent-catalog.ts +159 -0
- package/src/protocol/agent-command.ts +52 -7
- package/src/protocol/agent-content.ts +36 -16
- package/src/protocol/agent-event.ts +210 -73
- package/src/protocol/agent-identity.ts +29 -0
- package/src/protocol/agent-json.ts +122 -8
- package/src/protocol/agent-metadata.ts +2 -2
- package/src/protocol/agent-model.ts +13 -1
- package/src/protocol/agent-notification.ts +110 -0
- package/src/protocol/agent-presentation.ts +71 -20
- package/src/protocol/agent-tool.ts +66 -10
- package/src/protocol/agent-usage.ts +3 -1
- package/src/protocol/agent-user-input.ts +4 -4
- package/src/protocol/agent-work.ts +12 -10
- package/src/protocol/index.ts +2 -0
- package/src/protocol/rejected-keys.ts +11 -0
- package/src/provenance.gen.ts +3 -3
- package/src/supervisor-protocol/agent-event-wrapper.ts +30 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { HookCallbackMatcher, HookEvent, HookInput } from '@anthropic-ai/claude-agent-sdk'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { AgentProviderRuntimeError } from '../agent-runtime-error'
|
|
5
|
+
import { reconcileSelectedRepositoryInstructions, repositoryInstructionsUnavailable, selectedRepositoryInstructions } from '../selected-repository-instructions'
|
|
6
|
+
|
|
7
|
+
// A project rule, not a CLAUDE.md: Claude Code 2.1.280 turns its AGENTS.md fallback off for every
|
|
8
|
+
// nested repository once a CLAUDE.md, .claude/CLAUDE.md, or CLAUDE.local.md exists at the cwd.
|
|
9
|
+
const managedRulePath = ['.claude', 'rules', 'coding-agent-selected-repositories.md'] as const
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The Claude counterpart of Codex's managed AGENTS.override.md. Claude Code 2.1.280 (verified by
|
|
13
|
+
* claude-selected-repository-instructions.cli.spec.ts) loads an unconditional `.claude/rules/*.md`
|
|
14
|
+
* once per process into the first user message, never the cached system block, and on resume
|
|
15
|
+
* appends the changed copy itself. It does not re-read instruction files mid-session, so a
|
|
16
|
+
* repository added to a live run rewrites the file (for later resumes and compactions) and hands
|
|
17
|
+
* the new text to the main thread as hook context at its next tool boundary or submitted prompt.
|
|
18
|
+
*/
|
|
19
|
+
export class ClaudeSelectedRepositoryInstructions {
|
|
20
|
+
private pendingUpdate: string | undefined
|
|
21
|
+
|
|
22
|
+
private constructor(private readonly path: string, private readonly sharedContext: string | undefined) {}
|
|
23
|
+
|
|
24
|
+
static async open(input: { cwd: string, repositoryRoots: readonly string[], sharedContext?: string }): Promise<ClaudeSelectedRepositoryInstructions> {
|
|
25
|
+
const instructions = new ClaudeSelectedRepositoryInstructions(join(input.cwd, ...managedRulePath), input.sharedContext)
|
|
26
|
+
await instructions.publish(input.repositoryRoots)
|
|
27
|
+
return instructions
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async update(repositoryRoots: readonly string[]): Promise<void> {
|
|
31
|
+
await this.publish(repositoryRoots)
|
|
32
|
+
// A later update supersedes one the model has not received yet.
|
|
33
|
+
this.pendingUpdate = [
|
|
34
|
+
'The selected repositories for this session changed. These selected-repository instructions replace the earlier ones:',
|
|
35
|
+
'',
|
|
36
|
+
selectedRepositoryInstructions({ provider: 'claude', repositoryRoots, sharedContext: this.sharedContext }) ?? '',
|
|
37
|
+
].join('\n')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
hooks(): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
|
|
41
|
+
// Subagent hooks carry `agent_id`; only the main thread receives the update.
|
|
42
|
+
const take = (input: HookInput): string | undefined => {
|
|
43
|
+
if (input.agent_id !== undefined)
|
|
44
|
+
return undefined
|
|
45
|
+
const text = this.pendingUpdate
|
|
46
|
+
this.pendingUpdate = undefined
|
|
47
|
+
return text
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
PostToolUse: [{
|
|
51
|
+
hooks: [async (input) => {
|
|
52
|
+
const text = take(input)
|
|
53
|
+
return text ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } } : {}
|
|
54
|
+
}],
|
|
55
|
+
}],
|
|
56
|
+
PostToolUseFailure: [{
|
|
57
|
+
hooks: [async (input) => {
|
|
58
|
+
const text = take(input)
|
|
59
|
+
return text ? { hookSpecificOutput: { hookEventName: 'PostToolUseFailure', additionalContext: text } } : {}
|
|
60
|
+
}],
|
|
61
|
+
}],
|
|
62
|
+
UserPromptSubmit: [{
|
|
63
|
+
hooks: [async (input) => {
|
|
64
|
+
const text = take(input)
|
|
65
|
+
return text ? { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } } : {}
|
|
66
|
+
}],
|
|
67
|
+
}],
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async publish(repositoryRoots: readonly string[]): Promise<void> {
|
|
72
|
+
// Without content the reconcile only removes a stale managed rule; no directory is created.
|
|
73
|
+
if (selectedRepositoryInstructions({ provider: 'claude', repositoryRoots, sharedContext: this.sharedContext }) === undefined) {
|
|
74
|
+
await reconcileSelectedRepositoryInstructions({ provider: 'claude', path: this.path, repositoryRoots })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await mkdir(dirname(this.path), { recursive: true, mode: 0o750 })
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const cause = new AgentProviderRuntimeError({
|
|
82
|
+
message: 'Failed to create the Claude project rules directory.',
|
|
83
|
+
details: { instructionBridgePath: this.path },
|
|
84
|
+
retryable: true,
|
|
85
|
+
cause: error,
|
|
86
|
+
})
|
|
87
|
+
throw repositoryInstructionsUnavailable({ recoveryKind: 'retry', cause })
|
|
88
|
+
}
|
|
89
|
+
await reconcileSelectedRepositoryInstructions({ provider: 'claude', path: this.path, repositoryRoots, sharedContext: this.sharedContext })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SlashCommand } from '@anthropic-ai/claude-agent-sdk'
|
|
2
|
+
import type { AgentSkill } from '../../protocol'
|
|
3
|
+
import type { AgentEventDraft } from '../agent-event-stream'
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
type SessionSkillsUpdatedDraft = Extract<AgentEventDraft, { type: 'session.skills.updated' }>
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds the canonical `session.skills.updated` snapshot for Claude from the CLI's own command list
|
|
10
|
+
* (initialize response, `reloadPlugins`, and `system/commands_changed`), the Claude counterpart of
|
|
11
|
+
* `CodexSkillCatalog`. Only skills from the plugins this adapter installs are listed; Claude Code's
|
|
12
|
+
* built-in commands are not skills. Claude names plugin skills `<plugin>:<skill>` but also accepts
|
|
13
|
+
* the plain `<skill>` alias, so the catalog reports the plain name and `/<skill>` from the web
|
|
14
|
+
* composer invokes the same skill for both providers.
|
|
15
|
+
*
|
|
16
|
+
* Snapshots are held until the run's `system/init` validation publishes its lifecycle events, so a
|
|
17
|
+
* catalog never precedes `session.started`.
|
|
18
|
+
*/
|
|
19
|
+
export class ClaudeSkillCatalog {
|
|
20
|
+
private ready = false
|
|
21
|
+
private pending: AgentSkill[] | undefined
|
|
22
|
+
private lastSnapshot: string | undefined
|
|
23
|
+
|
|
24
|
+
constructor(private readonly input: {
|
|
25
|
+
pluginScopes: ReadonlyMap<string, AgentSkill['scope']>
|
|
26
|
+
emit: (draft: SessionSkillsUpdatedDraft) => void
|
|
27
|
+
}) {}
|
|
28
|
+
|
|
29
|
+
publish(commands: readonly SlashCommand[]): void {
|
|
30
|
+
const skills = claudeSkillsFromCommands(commands, this.input.pluginScopes)
|
|
31
|
+
if (!this.ready) {
|
|
32
|
+
this.pending = skills
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
this.emitIfChanged(skills)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
markReady(): void {
|
|
39
|
+
this.ready = true
|
|
40
|
+
const pending = this.pending
|
|
41
|
+
this.pending = undefined
|
|
42
|
+
if (pending)
|
|
43
|
+
this.emitIfChanged(pending)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private emitIfChanged(skills: AgentSkill[]): void {
|
|
47
|
+
const snapshot = JSON.stringify(skills)
|
|
48
|
+
if (snapshot === this.lastSnapshot)
|
|
49
|
+
return
|
|
50
|
+
this.lastSnapshot = snapshot
|
|
51
|
+
this.input.emit({ type: 'session.skills.updated', payload: { skills } })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function claudeSkillsFromCommands(commands: readonly SlashCommand[], pluginScopes: ReadonlyMap<string, AgentSkill['scope']>): AgentSkill[] {
|
|
56
|
+
return commands.flatMap((command) => {
|
|
57
|
+
// `builtin` is reported by SDK 0.3.280 and later; older runtimes list only non-builtin skills here.
|
|
58
|
+
if ('builtin' in command && command.builtin === true)
|
|
59
|
+
return []
|
|
60
|
+
const separator = command.name.indexOf(':')
|
|
61
|
+
const plugin = separator > 0 ? command.name.slice(0, separator) : undefined
|
|
62
|
+
const scope = plugin === undefined ? undefined : pluginScopes.get(plugin)
|
|
63
|
+
if (plugin === undefined || scope === undefined)
|
|
64
|
+
return []
|
|
65
|
+
const aliases = command.aliases ?? []
|
|
66
|
+
const name = aliases.find(alias => !alias.includes(':')) ?? command.name.slice(separator + 1)
|
|
67
|
+
const description = command.description.replace(`(${plugin}) `, '').trim() || name
|
|
68
|
+
return [{ id: skillId([command.name, ...aliases]), name, description, scope }]
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function skillId(names: readonly string[]): string {
|
|
73
|
+
const digest = createHash('sha256').update(`claude-skill\0${names.join('\0')}`).digest('hex')
|
|
74
|
+
return `skill_${digest}`
|
|
75
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { HookCallbackMatcher, HookEvent, HookInput } from '@anthropic-ai/claude-agent-sdk'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { basename, dirname, join } from 'node:path'
|
|
6
|
+
import z from 'zod'
|
|
7
|
+
import { AgentNotificationStoreError } from '../agent-runtime-error'
|
|
8
|
+
|
|
9
|
+
const storeDirectoryName = 'coding-agent-notifications'
|
|
10
|
+
const storeFileName = 'steered-notifications.json'
|
|
11
|
+
// Bounds the redrive-dedupe memory; a redrive arrives within the same command's retry window.
|
|
12
|
+
const deliveredCommandIdLimit = 200
|
|
13
|
+
|
|
14
|
+
const pendingSteeredNotificationSchema = z.strictObject({
|
|
15
|
+
commandId: z.string().min(1),
|
|
16
|
+
turnId: z.string().min(1),
|
|
17
|
+
text: z.string().min(1),
|
|
18
|
+
queuedAt: z.string().datetime(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const steeredNotificationStoreFileSchema = z.strictObject({
|
|
22
|
+
version: z.literal(1),
|
|
23
|
+
sessionId: z.string().min(1),
|
|
24
|
+
pending: z.array(pendingSteeredNotificationSchema),
|
|
25
|
+
delivered: z.array(z.strictObject({ commandId: z.string().min(1), turnId: z.string().min(1) })),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
type PendingSteeredNotification = z.infer<typeof pendingSteeredNotificationSchema>
|
|
29
|
+
type StoreFile = z.infer<typeof steeredNotificationStoreFileSchema>
|
|
30
|
+
|
|
31
|
+
export type SteeredNotificationEnqueueResult
|
|
32
|
+
= | { kind: 'queued' }
|
|
33
|
+
| { kind: 'duplicate', turnId: string }
|
|
34
|
+
|
|
35
|
+
/** Envelopes taken from the store together, and the one text that hands them to Claude. */
|
|
36
|
+
export interface SteeredNotificationDelivery {
|
|
37
|
+
readonly text: string
|
|
38
|
+
readonly notifications: readonly PendingSteeredNotification[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Notifications accepted into an active Claude turn wait here, in sandbox-local session state
|
|
43
|
+
* beside the user-input store, until a hook delivers them to the model. The receipt is returned
|
|
44
|
+
* only after the envelope is on disk, and an envelope leaves the store only when a hook hands it
|
|
45
|
+
* to Claude Code, so a process restart or session resume re-delivers what was accepted but not yet
|
|
46
|
+
* delivered, and a redriven command id that was already accepted is never queued twice.
|
|
47
|
+
*/
|
|
48
|
+
export class ClaudeSteeredNotificationStore {
|
|
49
|
+
private constructor(private readonly filePath: string, private file: StoreFile) {}
|
|
50
|
+
|
|
51
|
+
static open(input: { sessionId: string, transcriptPath: string }): ClaudeSteeredNotificationStore {
|
|
52
|
+
const filePath = join(
|
|
53
|
+
dirname(input.transcriptPath),
|
|
54
|
+
storeDirectoryName,
|
|
55
|
+
Buffer.from(input.sessionId, 'utf8').toString('base64url'),
|
|
56
|
+
storeFileName,
|
|
57
|
+
)
|
|
58
|
+
return new ClaudeSteeredNotificationStore(filePath, readStoreFile({ filePath, sessionId: input.sessionId }))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
hasPending(): boolean {
|
|
62
|
+
return this.file.pending.length > 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
enqueue(notification: Omit<PendingSteeredNotification, 'queuedAt'>): SteeredNotificationEnqueueResult {
|
|
66
|
+
const known = this.file.pending.find(entry => entry.commandId === notification.commandId)
|
|
67
|
+
?? this.file.delivered.find(entry => entry.commandId === notification.commandId)
|
|
68
|
+
if (known)
|
|
69
|
+
return { kind: 'duplicate', turnId: known.turnId }
|
|
70
|
+
this.write({ ...this.file, pending: [...this.file.pending, { ...notification, queuedAt: new Date().toISOString() }] })
|
|
71
|
+
return { kind: 'queued' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Hands every pending envelope to the caller and records them as delivered in one write. */
|
|
75
|
+
takePending(): SteeredNotificationDelivery | undefined {
|
|
76
|
+
const pending = this.file.pending
|
|
77
|
+
if (pending.length === 0)
|
|
78
|
+
return undefined
|
|
79
|
+
const delivered = [...this.file.delivered, ...pending.map(entry => ({ commandId: entry.commandId, turnId: entry.turnId }))]
|
|
80
|
+
this.write({ ...this.file, pending: [], delivered: delivered.slice(-deliveredCommandIdLimit) })
|
|
81
|
+
return { text: pending.map(entry => entry.text).join('\n\n'), notifications: pending }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Returns taken envelopes that Claude provably never received to the front of the queue. */
|
|
85
|
+
restore(delivery: SteeredNotificationDelivery): void {
|
|
86
|
+
const commandIds = new Set(delivery.notifications.map(entry => entry.commandId))
|
|
87
|
+
this.write({
|
|
88
|
+
...this.file,
|
|
89
|
+
pending: [...delivery.notifications, ...this.file.pending.filter(entry => !commandIds.has(entry.commandId))],
|
|
90
|
+
delivered: this.file.delivered.filter(entry => !commandIds.has(entry.commandId)),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private write(file: StoreFile): void {
|
|
95
|
+
const parent = dirname(this.filePath)
|
|
96
|
+
const tempPath = join(parent, `.${basename(this.filePath)}.${process.pid}.${randomUUID()}.tmp`)
|
|
97
|
+
try {
|
|
98
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 })
|
|
99
|
+
writeFileSync(tempPath, `${JSON.stringify(file)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
100
|
+
renameSync(tempPath, this.filePath)
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
throw new AgentNotificationStoreError({
|
|
104
|
+
message: `Failed to persist steered notifications for session ${file.sessionId}.`,
|
|
105
|
+
details: { sessionId: file.sessionId },
|
|
106
|
+
cause: error,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
this.file = file
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readStoreFile(input: { filePath: string, sessionId: string }): StoreFile {
|
|
114
|
+
let raw: string
|
|
115
|
+
try {
|
|
116
|
+
raw = readFileSync(input.filePath, 'utf8')
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
120
|
+
return { version: 1, sessionId: input.sessionId, pending: [], delivered: [] }
|
|
121
|
+
throw new AgentNotificationStoreError({ message: `Failed to read steered notifications for session ${input.sessionId}.`, details: { sessionId: input.sessionId }, cause: error })
|
|
122
|
+
}
|
|
123
|
+
let json: unknown
|
|
124
|
+
try {
|
|
125
|
+
json = JSON.parse(raw)
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
throw new AgentNotificationStoreError({ message: `Steered notifications for session ${input.sessionId} are not valid JSON.`, details: { sessionId: input.sessionId }, cause: error })
|
|
129
|
+
}
|
|
130
|
+
const parsed = steeredNotificationStoreFileSchema.safeParse(json)
|
|
131
|
+
if (!parsed.success || parsed.data.sessionId !== input.sessionId)
|
|
132
|
+
throw new AgentNotificationStoreError({ message: `Steered notifications for session ${input.sessionId} are invalid.`, details: { sessionId: input.sessionId } })
|
|
133
|
+
return parsed.data
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Delivers pending steered notifications inside the current main turn (verified on Claude Code
|
|
138
|
+
* 2.1.280): the next main-thread tool boundary gets them as PostToolUse(Failure) additional
|
|
139
|
+
* context, and a turn that tries to end first is continued by a Stop-hook block whose reason is the
|
|
140
|
+
* envelope. An interrupted or failed turn fires neither, so the next submitted input receives them
|
|
141
|
+
* as UserPromptSubmit context, which also covers envelopes recovered after a restart or resume.
|
|
142
|
+
* A notify that lands after the Stop hook passed is delivered by the run's follow-up turn (see
|
|
143
|
+
* ClaudeRun). Subagent hooks carry `agent_id` and never consume them, and neither does the
|
|
144
|
+
* synthetic `/compact` input: compaction is not a turn the model answers.
|
|
145
|
+
*/
|
|
146
|
+
export function claudeSteeredNotificationHooks(store: ClaudeSteeredNotificationStore): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
|
|
147
|
+
const take = (input: HookInput): string | undefined => input.agent_id === undefined && !isCompactCommand(input) ? store.takePending()?.text : undefined
|
|
148
|
+
return {
|
|
149
|
+
PostToolUse: [{
|
|
150
|
+
hooks: [async (input) => {
|
|
151
|
+
const text = take(input)
|
|
152
|
+
return text ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text } } : {}
|
|
153
|
+
}],
|
|
154
|
+
}],
|
|
155
|
+
PostToolUseFailure: [{
|
|
156
|
+
hooks: [async (input) => {
|
|
157
|
+
const text = take(input)
|
|
158
|
+
return text ? { hookSpecificOutput: { hookEventName: 'PostToolUseFailure', additionalContext: text } } : {}
|
|
159
|
+
}],
|
|
160
|
+
}],
|
|
161
|
+
Stop: [{
|
|
162
|
+
hooks: [async (input) => {
|
|
163
|
+
const text = take(input)
|
|
164
|
+
return text ? { decision: 'block', reason: text } : {}
|
|
165
|
+
}],
|
|
166
|
+
}],
|
|
167
|
+
UserPromptSubmit: [{
|
|
168
|
+
hooks: [async (input) => {
|
|
169
|
+
const text = take(input)
|
|
170
|
+
return text ? { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } } : {}
|
|
171
|
+
}],
|
|
172
|
+
}],
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// CLI 2.1.280 runs no UserPromptSubmit hook for the synthetic `/compact` input (live-probed); this
|
|
177
|
+
// keeps envelopes for a real prompt if a later runtime starts to.
|
|
178
|
+
function isCompactCommand(input: HookInput): boolean {
|
|
179
|
+
return input.hook_event_name === 'UserPromptSubmit' && /^\/compact(?:\s|$)/u.test(input.prompt.trimStart())
|
|
180
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { Options } from '@anthropic-ai/claude-agent-sdk'
|
|
1
|
+
import type { Options, PostToolUseFailureHookInput, PostToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'
|
|
2
2
|
import type { AgentRunMetadata, AgentUserInputQuestion, JsonValue } from '../../protocol'
|
|
3
3
|
import type { AgentTool } from '../agent-tool'
|
|
4
|
-
import { tool as claudeTool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'
|
|
4
|
+
import { tool as claudeTool, createSdkMcpServer, HOOK_EVENTS } from '@anthropic-ai/claude-agent-sdk'
|
|
5
5
|
import { isAppError } from '../../errors'
|
|
6
6
|
import { AgentProviderProtocolError } from '../agent-runtime-error'
|
|
7
7
|
import { agentToolActorDenialMessage, agentToolAllowsActor } from '../agent-tool'
|
|
@@ -12,6 +12,15 @@ export const CLAUDE_CODE_TOOL_USE_ID_META_KEY = 'claudecode/toolUseId'
|
|
|
12
12
|
|
|
13
13
|
const UNEXPECTED_TOOL_FAILURE_MESSAGE = 'The tool could not be executed.'
|
|
14
14
|
|
|
15
|
+
export type ClaudeNativeToolActor = Readonly<{ kind: 'main' }> | Readonly<{ kind: 'subagent', id: string }>
|
|
16
|
+
|
|
17
|
+
type ClaudeNativeToolActorHooksInput = Readonly<{
|
|
18
|
+
expectedSessionId: string
|
|
19
|
+
toolNames: readonly string[]
|
|
20
|
+
register: (input: { toolName: string, toolUseId: string, actor: ClaudeNativeToolActor, signal: AbortSignal }) => Promise<void>
|
|
21
|
+
finish: (input: { toolUseId: string }) => Promise<void>
|
|
22
|
+
}>
|
|
23
|
+
|
|
15
24
|
interface ClaudeMcpToolExtra {
|
|
16
25
|
_meta?: {
|
|
17
26
|
[CLAUDE_CODE_TOOL_USE_ID_META_KEY]?: unknown
|
|
@@ -113,6 +122,76 @@ export function claudeAllowedToolNames(tools: readonly AgentTool[]): string[] {
|
|
|
113
122
|
return tools.map(runtimeTool => claudeToolName(runtimeTool.name))
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
export function claudeNativeToolActorHooks(input: ClaudeNativeToolActorHooksInput): NonNullable<Options['hooks']> {
|
|
126
|
+
const toolNames = new Set(input.toolNames)
|
|
127
|
+
const denial = () => ({
|
|
128
|
+
hookSpecificOutput: {
|
|
129
|
+
hookEventName: 'PreToolUse' as const,
|
|
130
|
+
permissionDecision: 'deny' as const,
|
|
131
|
+
permissionDecisionReason: 'The native tool caller could not be attributed.',
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
const finish = async (hookInput: PostToolUseHookInput): Promise<Record<string, never>> => {
|
|
135
|
+
if (hookInput.hook_event_name !== 'PostToolUse' || !toolNames.has(hookInput.tool_name))
|
|
136
|
+
return {}
|
|
137
|
+
try {
|
|
138
|
+
await input.finish({ toolUseId: hookInput.tool_use_id })
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// The registration is already unusable after the native call; cleanup cannot rewrite its result.
|
|
142
|
+
}
|
|
143
|
+
return {}
|
|
144
|
+
}
|
|
145
|
+
const finishFailure = async (hookInput: PostToolUseFailureHookInput): Promise<Record<string, never>> => {
|
|
146
|
+
if (hookInput.hook_event_name !== 'PostToolUseFailure' || !toolNames.has(hookInput.tool_name))
|
|
147
|
+
return {}
|
|
148
|
+
try {
|
|
149
|
+
await input.finish({ toolUseId: hookInput.tool_use_id })
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// The failed native call already has its outcome; cleanup remains best effort.
|
|
153
|
+
}
|
|
154
|
+
return {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
PreToolUse: [{
|
|
159
|
+
hooks: [async (hookInput, toolUseId, options) => {
|
|
160
|
+
if (hookInput.hook_event_name !== 'PreToolUse' || !toolNames.has(hookInput.tool_name))
|
|
161
|
+
return {}
|
|
162
|
+
if (hookInput.session_id !== input.expectedSessionId || toolUseId !== hookInput.tool_use_id)
|
|
163
|
+
return denial()
|
|
164
|
+
const actor: ClaudeNativeToolActor = hookInput.agent_id === undefined
|
|
165
|
+
? { kind: 'main' }
|
|
166
|
+
: { kind: 'subagent', id: hookInput.agent_id }
|
|
167
|
+
try {
|
|
168
|
+
await input.register({ toolName: hookInput.tool_name, toolUseId: hookInput.tool_use_id, actor, signal: options.signal })
|
|
169
|
+
return {}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return denial()
|
|
173
|
+
}
|
|
174
|
+
}],
|
|
175
|
+
}],
|
|
176
|
+
PostToolUse: [{ hooks: [async (hookInput, _toolUseId, _options) => hookInput.hook_event_name === 'PostToolUse'
|
|
177
|
+
? await finish(hookInput)
|
|
178
|
+
: {}] }],
|
|
179
|
+
PostToolUseFailure: [{ hooks: [async (hookInput, _toolUseId, _options) => hookInput.hook_event_name === 'PostToolUseFailure'
|
|
180
|
+
? await finishFailure(hookInput)
|
|
181
|
+
: {}] }],
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function mergeClaudeToolHooks(...hooks: Array<Options['hooks']>): Options['hooks'] {
|
|
186
|
+
const merged: NonNullable<Options['hooks']> = {}
|
|
187
|
+
for (const event of HOOK_EVENTS) {
|
|
188
|
+
const matchers = hooks.flatMap(value => value?.[event] ?? [])
|
|
189
|
+
if (matchers.length > 0)
|
|
190
|
+
merged[event] = matchers
|
|
191
|
+
}
|
|
192
|
+
return Object.keys(merged).length > 0 ? merged : undefined
|
|
193
|
+
}
|
|
194
|
+
|
|
116
195
|
export function claudeToolActorHooks(tools: readonly AgentTool[]): Options['hooks'] {
|
|
117
196
|
const toolsByName = new Map(
|
|
118
197
|
tools
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { mkdir, readdir, readlink, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { stableRepositoryRoots } from '../codex/codex-skill-roots'
|
|
4
|
+
|
|
5
|
+
/** Plugin name of the generated per-run workspace skills plugin; Claude lists its skills as `workspace:<name>`. */
|
|
6
|
+
export const claudeWorkspaceSkillsPluginName = 'workspace'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Claude Code does not read `<repository>/.agents/skills`, and a plugin's `skills` directory is read
|
|
10
|
+
* exactly one level deep. Each run therefore owns a generated local plugin whose `skills` directory
|
|
11
|
+
* holds one symlink per repository skill, the Claude counterpart of Codex `skills/extraRoots/set`.
|
|
12
|
+
* The first repository (in sorted root order) keeps a skill's own directory name; a later
|
|
13
|
+
* repository's same-named skill gets a `<repository>--<skill>` link, so both stay listed, as in the
|
|
14
|
+
* Codex catalog, and `/<skill>` resolves to the first.
|
|
15
|
+
*/
|
|
16
|
+
export class ClaudeWorkspaceSkills {
|
|
17
|
+
private constructor(readonly pluginPath: string) {}
|
|
18
|
+
|
|
19
|
+
static async create(pluginPath: string): Promise<ClaudeWorkspaceSkills> {
|
|
20
|
+
await mkdir(join(pluginPath, '.claude-plugin'), { recursive: true, mode: 0o700 })
|
|
21
|
+
await mkdir(join(pluginPath, 'skills'), { recursive: true, mode: 0o700 })
|
|
22
|
+
await writeFile(join(pluginPath, '.claude-plugin', 'plugin.json'), `${JSON.stringify({
|
|
23
|
+
name: claudeWorkspaceSkillsPluginName,
|
|
24
|
+
description: 'Skills from the session\'s workspace repositories (.agents/skills).',
|
|
25
|
+
})}\n`)
|
|
26
|
+
return new ClaudeWorkspaceSkills(pluginPath)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Links the current skills of every repository root; returns whether the linked set changed. */
|
|
30
|
+
async sync(repositoryRoots: readonly string[]): Promise<boolean> {
|
|
31
|
+
const skillsDirectory = join(this.pluginPath, 'skills')
|
|
32
|
+
const desired = await desiredSkillLinks(repositoryRoots)
|
|
33
|
+
let changed = false
|
|
34
|
+
for (const entry of await readdir(skillsDirectory)) {
|
|
35
|
+
const linkPath = join(skillsDirectory, entry)
|
|
36
|
+
const target = desired.get(entry)
|
|
37
|
+
if (target !== undefined && await readlink(linkPath).catch(() => undefined) === target)
|
|
38
|
+
continue
|
|
39
|
+
await unlink(linkPath)
|
|
40
|
+
changed = true
|
|
41
|
+
}
|
|
42
|
+
for (const [name, target] of desired) {
|
|
43
|
+
const linkPath = join(skillsDirectory, name)
|
|
44
|
+
if (await readlink(linkPath).catch(() => undefined) === target)
|
|
45
|
+
continue
|
|
46
|
+
await symlink(target, linkPath)
|
|
47
|
+
changed = true
|
|
48
|
+
}
|
|
49
|
+
return changed
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async remove(): Promise<void> {
|
|
53
|
+
await rm(this.pluginPath, { recursive: true, force: true })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function desiredSkillLinks(repositoryRoots: readonly string[]): Promise<Map<string, string>> {
|
|
58
|
+
const links = new Map<string, string>()
|
|
59
|
+
for (const root of stableRepositoryRoots(repositoryRoots)) {
|
|
60
|
+
const skillsRoot = join(root, '.agents', 'skills')
|
|
61
|
+
for (const skill of await skillDirectories(skillsRoot)) {
|
|
62
|
+
const target = join(skillsRoot, skill)
|
|
63
|
+
let name = skill
|
|
64
|
+
for (let suffix = 1; links.has(name); suffix += 1)
|
|
65
|
+
name = suffix === 1 ? `${linkSegment(basename(root))}--${skill}` : `${linkSegment(basename(root))}--${skill}-${suffix}`
|
|
66
|
+
links.set(name, target)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return links
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function skillDirectories(skillsRoot: string): Promise<string[]> {
|
|
73
|
+
const entries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => [])
|
|
74
|
+
const skills: string[] = []
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (entry.name.startsWith('.') || !(entry.isDirectory() || entry.isSymbolicLink()))
|
|
77
|
+
continue
|
|
78
|
+
const manifest = await stat(join(skillsRoot, entry.name, 'SKILL.md')).catch(() => undefined)
|
|
79
|
+
if (manifest?.isFile())
|
|
80
|
+
skills.push(entry.name)
|
|
81
|
+
}
|
|
82
|
+
return skills.sort()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function linkSegment(value: string): string {
|
|
86
|
+
return value.replaceAll(/[^\w.-]/gu, '-') || 'repository'
|
|
87
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CodexAuthRefreshInput, CodexAuthTokens } from './codex-auth'
|
|
2
|
+
import type { ClientRequestParamsByMethod, CodexChatGPTAuthTokensRefreshParams, CodexChatGPTAuthTokensRefreshResponse } from './codex-protocol'
|
|
3
|
+
|
|
4
|
+
export function toCodexLoginParams(tokens: CodexAuthTokens): ClientRequestParamsByMethod['account/login/start'] {
|
|
5
|
+
return {
|
|
6
|
+
type: 'chatgptAuthTokens',
|
|
7
|
+
accessToken: tokens.accessToken,
|
|
8
|
+
chatgptAccountId: tokens.chatgptAccountId,
|
|
9
|
+
chatgptPlanType: tokens.chatgptPlanType ?? null,
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toAuthRefreshInput(params: CodexChatGPTAuthTokensRefreshParams, tokens: CodexAuthTokens): CodexAuthRefreshInput {
|
|
14
|
+
return {
|
|
15
|
+
reason: params.reason,
|
|
16
|
+
rejectedTokenGeneration: tokens.accessTokenGeneration,
|
|
17
|
+
rejectedTokenFingerprint: tokens.accessTokenFingerprint,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toCodexRefreshResponse(tokens: CodexAuthTokens): CodexChatGPTAuthTokensRefreshResponse {
|
|
22
|
+
return {
|
|
23
|
+
accessToken: tokens.accessToken,
|
|
24
|
+
chatgptAccountId: tokens.chatgptAccountId,
|
|
25
|
+
chatgptPlanType: tokens.chatgptPlanType ?? null,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
const tokenExpirySchema = z.object({ exp: z.number().finite() })
|
|
5
|
+
|
|
1
6
|
export interface CodexAuthTokens {
|
|
2
7
|
accessToken: string
|
|
3
8
|
accessTokenGeneration: number
|
|
@@ -43,3 +48,17 @@ export type CodexAuthentication
|
|
|
43
48
|
= | { kind: 'chatgpt', provider: CodexAuthProvider }
|
|
44
49
|
| { kind: 'api-key', provider: CodexApiKeyAuthProvider, modelProvider?: string }
|
|
45
50
|
| { kind: 'amazon-bedrock', region: string, provider: CodexBedrockAuthProvider }
|
|
51
|
+
/** Expiry scheduling hint only; this neither authenticates nor verifies a JWT signature. */
|
|
52
|
+
export function codexAuthTokensExpireSoon(tokens: CodexAuthTokens): boolean {
|
|
53
|
+
const parts = tokens.accessToken.split('.')
|
|
54
|
+
if (parts.length !== 3 || !parts[1])
|
|
55
|
+
return false
|
|
56
|
+
try {
|
|
57
|
+
const payload = tokenExpirySchema.safeParse(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')))
|
|
58
|
+
return payload.success && payload.data.exp <= Date.now() / 1000 + 60
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Opaque or non-JWT credentials retain native/explicit refresh; invent no expiry.
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
}
|