@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.
Files changed (71) hide show
  1. package/README.md +50 -0
  2. package/docs/consumer-capability-matrix.md +374 -0
  3. package/package.json +17 -10
  4. package/scripts/generate-codex-protocol.ts +711 -0
  5. package/src/agents/agent-operation.ts +8 -0
  6. package/src/agents/agent-provider.ts +3 -0
  7. package/src/agents/agent-run.ts +14 -2
  8. package/src/agents/agent-runtime-error.ts +28 -0
  9. package/src/agents/agent-session-events.ts +33 -0
  10. package/src/agents/agent-tool-runner.ts +12 -11
  11. package/src/agents/agent-tool.ts +12 -4
  12. package/src/agents/author-context.ts +1 -1
  13. package/src/agents/claude/claude-auth.ts +40 -0
  14. package/src/agents/claude/claude-browser-turn-metadata.ts +70 -0
  15. package/src/agents/claude/claude-code-process.ts +98 -0
  16. package/src/agents/claude/claude-command-mapper.ts +63 -3
  17. package/src/agents/claude/claude-desktop.ts +55 -0
  18. package/src/agents/claude/claude-event-mapper.ts +868 -135
  19. package/src/agents/claude/claude-file-change.ts +128 -0
  20. package/src/agents/claude/claude-provider.ts +356 -30
  21. package/src/agents/claude/claude-run.ts +898 -130
  22. package/src/agents/claude/claude-selected-repository-instructions.ts +91 -0
  23. package/src/agents/claude/claude-skill-catalog.ts +75 -0
  24. package/src/agents/claude/claude-steered-notifications.ts +180 -0
  25. package/src/agents/claude/claude-tool-mapper.ts +81 -2
  26. package/src/agents/claude/claude-workspace-skills.ts +87 -0
  27. package/src/agents/claude/index.ts +2 -0
  28. package/src/agents/codex/codex-auth-mapper.ts +27 -0
  29. package/src/agents/codex/codex-auth.ts +19 -0
  30. package/src/agents/codex/codex-browser-auth-broker.ts +320 -0
  31. package/src/agents/codex/codex-browser-auth-status.ts +55 -0
  32. package/src/agents/codex/codex-command-mapper.ts +38 -3
  33. package/src/agents/codex/codex-event-mapper.ts +384 -67
  34. package/src/agents/codex/codex-json-rpc-client.ts +66 -21
  35. package/src/agents/codex/codex-provider.ts +118 -236
  36. package/src/agents/codex/codex-run-auth.ts +113 -0
  37. package/src/agents/codex/codex-run.ts +423 -53
  38. package/src/agents/codex/codex-tool-mapper.ts +2 -2
  39. package/src/agents/codex/codex.errors.ts +8 -0
  40. package/src/agents/codex/generated/meta.gen.ts +144 -6
  41. package/src/agents/codex/generated/namespaces.gen.ts +92 -3
  42. package/src/agents/codex/generated/schema.gen.ts +24698 -15638
  43. package/src/agents/codex/lazy-codex-run-auth.ts +47 -0
  44. package/src/agents/index.ts +5 -0
  45. package/src/agents/input-attachment-preparation.ts +13 -0
  46. package/src/agents/mcp-tool-content.ts +82 -0
  47. package/src/agents/model-rebinding-run.ts +14 -3
  48. package/src/agents/selected-repository-instructions.ts +202 -0
  49. package/src/agents/session-context.ts +41 -0
  50. package/src/agents/subscription-token-refresh.ts +73 -0
  51. package/src/agents/user-input-request.ts +19 -20
  52. package/src/errors/index.ts +1 -1
  53. package/src/errors/validation-error.ts +6 -0
  54. package/src/protocol/agent-catalog.ts +159 -0
  55. package/src/protocol/agent-command.ts +52 -7
  56. package/src/protocol/agent-content.ts +36 -16
  57. package/src/protocol/agent-event.ts +210 -73
  58. package/src/protocol/agent-identity.ts +29 -0
  59. package/src/protocol/agent-json.ts +122 -8
  60. package/src/protocol/agent-metadata.ts +2 -2
  61. package/src/protocol/agent-model.ts +13 -1
  62. package/src/protocol/agent-notification.ts +110 -0
  63. package/src/protocol/agent-presentation.ts +71 -20
  64. package/src/protocol/agent-tool.ts +66 -10
  65. package/src/protocol/agent-usage.ts +3 -1
  66. package/src/protocol/agent-user-input.ts +4 -4
  67. package/src/protocol/agent-work.ts +12 -10
  68. package/src/protocol/index.ts +2 -0
  69. package/src/protocol/rejected-keys.ts +11 -0
  70. package/src/provenance.gen.ts +3 -3
  71. 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
+ }
@@ -1 +1,3 @@
1
+ export * from './claude-auth'
2
+ export * from './claude-desktop'
1
3
  export * from './claude-provider'
@@ -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
+ }