@meistrari/agent-core 0.0.0 → 0.1.0

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 (83) hide show
  1. package/README.md +24 -3
  2. package/bin/supervisor.ts +116 -0
  3. package/package.json +42 -3
  4. package/scripts/build-supervisor-executable.ts +32 -0
  5. package/src/agents/agent-error-serializer.ts +23 -0
  6. package/src/agents/agent-event-stream.ts +81 -0
  7. package/src/agents/agent-id.ts +17 -0
  8. package/src/agents/agent-operation.ts +11 -0
  9. package/src/agents/agent-provider.ts +37 -0
  10. package/src/agents/agent-run.ts +32 -0
  11. package/src/agents/agent-runtime-error.ts +161 -0
  12. package/src/agents/agent-session-events.ts +32 -0
  13. package/src/agents/agent-tool-runner.ts +83 -0
  14. package/src/agents/agent-tool.ts +111 -0
  15. package/src/agents/author-context.ts +36 -0
  16. package/src/agents/claude/claude-command-mapper.ts +234 -0
  17. package/src/agents/claude/claude-event-mapper.ts +736 -0
  18. package/src/agents/claude/claude-provider.ts +191 -0
  19. package/src/agents/claude/claude-run.ts +464 -0
  20. package/src/agents/claude/claude-tool-mapper.ts +186 -0
  21. package/src/agents/claude/index.ts +1 -0
  22. package/src/agents/codex/codex-auth.ts +34 -0
  23. package/src/agents/codex/codex-command-mapper.ts +78 -0
  24. package/src/agents/codex/codex-event-mapper.ts +708 -0
  25. package/src/agents/codex/codex-json-rpc-client.ts +326 -0
  26. package/src/agents/codex/codex-protocol.ts +36 -0
  27. package/src/agents/codex/codex-provider.ts +1050 -0
  28. package/src/agents/codex/codex-run.ts +404 -0
  29. package/src/agents/codex/codex-skill-catalog.ts +158 -0
  30. package/src/agents/codex/codex-skill-roots.ts +19 -0
  31. package/src/agents/codex/codex-tool-mapper.ts +55 -0
  32. package/src/agents/codex/codex.errors.ts +68 -0
  33. package/src/agents/codex/generated/meta.gen.ts +606 -0
  34. package/src/agents/codex/generated/namespaces.gen.ts +311 -0
  35. package/src/agents/codex/generated/schema.gen.ts +34883 -0
  36. package/src/agents/codex/index.ts +2 -0
  37. package/src/agents/index.ts +14 -0
  38. package/src/agents/input-attachment-preparation.ts +86 -0
  39. package/src/agents/input-attachment.errors.ts +16 -0
  40. package/src/agents/instructions.ts +8 -0
  41. package/src/agents/materialized-input-attachment.ts +26 -0
  42. package/src/agents/message-id.ts +23 -0
  43. package/src/agents/normalize.ts +8 -0
  44. package/src/agents/sandbox-environment.ts +1 -0
  45. package/src/agents/tools/ping.tool.ts +13 -0
  46. package/src/agents/user-input-request.ts +470 -0
  47. package/src/provenance.gen.ts +3 -3
  48. package/src/supervisor/agent-provider-factory.ts +189 -0
  49. package/src/supervisor/bootstrap-binder.ts +125 -0
  50. package/src/supervisor/config.ts +49 -0
  51. package/src/supervisor/control-authority-verifier.ts +135 -0
  52. package/src/supervisor/create-supervisor-runtime.ts +25 -0
  53. package/src/supervisor/errors.ts +24 -0
  54. package/src/supervisor/index.ts +34 -0
  55. package/src/supervisor/persistence/json.ts +21 -0
  56. package/src/supervisor/persistence/state-discovery.ts +56 -0
  57. package/src/supervisor/persistence/supervisor-store.ts +364 -0
  58. package/src/supervisor/ports/index.ts +109 -0
  59. package/src/supervisor/provider-factory.ts +37 -0
  60. package/src/supervisor/resident.ts +143 -0
  61. package/src/supervisor/rpc-client.ts +120 -0
  62. package/src/supervisor/runtime-handler.ts +309 -0
  63. package/src/supervisor/websocket-server.ts +434 -0
  64. package/src/supervisor-protocol/bootstrap.ts +1 -1
  65. package/src/template-onboarding.ts +47 -0
  66. package/src/testing/es256-test-keys.ts +73 -0
  67. package/src/testing/in-memory-runtime-control-plane.ts +205 -0
  68. package/src/testing/index.ts +6 -0
  69. package/src/testing/loopback-supervisor-connection.ts +71 -0
  70. package/src/testing/scripted-provider.ts +64 -0
  71. package/src/worker-runtime-client/command-pump.ts +132 -0
  72. package/src/worker-runtime-client/connection-attempt.ts +340 -0
  73. package/src/worker-runtime-client/control-authority-signer.ts +100 -0
  74. package/src/worker-runtime-client/e2b-supervisor-connection.ts +102 -0
  75. package/src/worker-runtime-client/frame-processor.ts +178 -0
  76. package/src/worker-runtime-client/index.ts +27 -0
  77. package/src/worker-runtime-client/lease-reconciler.ts +14 -0
  78. package/src/worker-runtime-client/ports.ts +137 -0
  79. package/src/worker-runtime-client/postgres-notification-listener.ts +91 -0
  80. package/src/worker-runtime-client/rpc-dispatcher.ts +27 -0
  81. package/src/worker-runtime-client/rpc-request-manager.ts +141 -0
  82. package/src/worker-runtime-client/sandbox-connection-runtime.ts +300 -0
  83. package/src/worker-runtime-client/token-crypto.ts +46 -0
@@ -0,0 +1,404 @@
1
+ import type {
2
+ AgentAuthor,
3
+ AgentPrompt,
4
+ AgentReasoningEffort,
5
+ AgentRunMetadata,
6
+ InterruptAgentCommand,
7
+ RespondUserInputAgentCommand,
8
+ SendPromptCommand,
9
+ StopAgentCommand,
10
+ } from '../../protocol'
11
+ import type { AgentEventDraft } from '../agent-event-stream'
12
+ import type { AgentOperationOptions } from '../agent-operation'
13
+ import type { AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
14
+ import type { UserInputRequestInput, UserInputRequestStore } from '../user-input-request'
15
+ import type { CodexEventMapperState } from './codex-event-mapper'
16
+ import type { CodexJsonRpcClient } from './codex-json-rpc-client'
17
+ import type { ClientRequestResponsesByMethod, CodexTurnStartParams, CodexUserInput } from './codex-protocol'
18
+ import { AgentEventStream } from '../agent-event-stream'
19
+ import { AgentDeliveryUnknownError, AgentNotAcceptedError, AgentPromptDeferredError, AgentRunStateError } from '../agent-runtime-error'
20
+ import { deriveMessageId } from '../message-id'
21
+ import { codexEffort, prepareCodexUserInput, toCodexUserInput } from './codex-command-mapper'
22
+ import { CodexJsonRpcResponseError } from './codex-json-rpc-client'
23
+ import { CodexSkillCatalog } from './codex-skill-catalog'
24
+ import { catalogRepositorySkillRoots, workspaceSkillRoots } from './codex-skill-roots'
25
+ import { CodexJsonRpcTransportClosedError } from './codex.errors'
26
+
27
+ const codexReasoningSummary: CodexTurnStartParams['summary'] = 'auto'
28
+
29
+ interface CodexRunAuthFailureInput {
30
+ reason: 'codex_auth_refresh_failed'
31
+ error: { message: string, name?: string, stack?: string }
32
+ }
33
+
34
+ interface PreparedCodexTurn {
35
+ input: CodexUserInput[]
36
+ commandId: string
37
+ origin: 'user' | 'supervisor'
38
+ }
39
+
40
+ interface CodexRunInput {
41
+ metadata: AgentRunMetadata
42
+ reasoningEffort?: AgentReasoningEffort
43
+ client: CodexJsonRpcClient
44
+ requestedCwd: string
45
+ workspaceRepositoryRoots: readonly string[]
46
+ registeredSkillRoots: readonly string[]
47
+ repositorySkillRoots: readonly string[]
48
+ reconcileInstructionBridge: (repositoryRoots: readonly string[]) => Promise<void>
49
+ userInputRequests: UserInputRequestStore
50
+ eventMapperState: CodexEventMapperState
51
+ abortSignal: AbortSignal
52
+ onStop: () => void
53
+ }
54
+
55
+ export class CodexRun implements AgentRun {
56
+ readonly events: AgentEventStream
57
+ readonly metadata: AgentRunMetadata
58
+ private readonly client: CodexJsonRpcClient
59
+ private readonly reasoningEffort: AgentReasoningEffort | undefined
60
+ private readonly onStop: () => void
61
+ private readonly userInputRequests: UserInputRequestStore
62
+ private readonly eventMapperState: CodexEventMapperState
63
+ private readonly skillCatalog: CodexSkillCatalog
64
+ private readonly requestedCwd: string
65
+ private readonly reconcileInstructionBridge: (repositoryRoots: readonly string[]) => Promise<void>
66
+ private workspaceRepositoryRoots: readonly string[]
67
+ private registeredSkillRoots: readonly string[]
68
+ private workspacePrepared = false
69
+ private stopped = false
70
+ private stoppingTask: Promise<void> | undefined
71
+ private activeTurnId: string | undefined
72
+ private readonly interruptingTurnIds = new Set<string>()
73
+
74
+ constructor(input: CodexRunInput) {
75
+ this.metadata = input.metadata
76
+ this.client = input.client
77
+ this.reasoningEffort = input.reasoningEffort
78
+ this.onStop = input.onStop
79
+ this.userInputRequests = input.userInputRequests
80
+ this.eventMapperState = input.eventMapperState
81
+ this.requestedCwd = input.requestedCwd
82
+ this.workspaceRepositoryRoots = [...input.workspaceRepositoryRoots]
83
+ this.registeredSkillRoots = input.registeredSkillRoots
84
+ this.reconcileInstructionBridge = input.reconcileInstructionBridge
85
+ this.events = new AgentEventStream({
86
+ provider: 'codex',
87
+ sessionId: input.metadata.sessionId,
88
+ providerSessionId: input.metadata.providerSessionId,
89
+ })
90
+ this.skillCatalog = new CodexSkillCatalog({
91
+ client: input.client,
92
+ cwd: input.metadata.cwd,
93
+ repositorySkillRoots: input.repositorySkillRoots,
94
+ emitSnapshot: draft => this.pushEvent(draft),
95
+ emitError: draft => this.pushEvent(draft),
96
+ })
97
+ input.abortSignal.addEventListener('abort', () => void this.stop({
98
+ type: 'agent.stop',
99
+ provider: 'codex',
100
+ sessionId: this.metadata.sessionId,
101
+ providerSessionId: this.metadata.providerSessionId,
102
+ reason: 'aborted',
103
+ }))
104
+ }
105
+
106
+ pushEvent(draft: AgentEventDraft): void {
107
+ if (this.stopped)
108
+ return
109
+ const decorated = this.userInputRequests.decorateTurnEnded(draft)
110
+ if (decorated.type === 'session.state.changed' && decorated.payload.state === 'idle' && this.userInputRequests.shouldHoldIdle()) {
111
+ this.events.push({ type: 'session.state.changed', payload: { state: 'waiting_for_user_input' } })
112
+ return
113
+ }
114
+ this.events.push(decorated)
115
+ }
116
+
117
+ async prepareWorkspace(options: AgentOperationOptions = {}): Promise<void> {
118
+ this.assertRunning()
119
+ if (this.workspacePrepared)
120
+ return
121
+
122
+ const abort = () => void this.stop({
123
+ type: 'agent.stop',
124
+ provider: 'codex',
125
+ sessionId: this.metadata.sessionId,
126
+ providerSessionId: this.metadata.providerSessionId,
127
+ reason: 'workspace_preparation_aborted',
128
+ }).catch(() => undefined)
129
+ if (options.signal?.aborted)
130
+ abort()
131
+ else
132
+ options.signal?.addEventListener('abort', abort, { once: true })
133
+ try {
134
+ options.signal?.throwIfAborted()
135
+ await this.client.request('skills/extraRoots/set', {
136
+ extraRoots: [...this.registeredSkillRoots],
137
+ })
138
+ options.signal?.throwIfAborted()
139
+ }
140
+ catch (error) {
141
+ await this.stop({
142
+ type: 'agent.stop',
143
+ provider: 'codex',
144
+ sessionId: this.metadata.sessionId,
145
+ providerSessionId: this.metadata.providerSessionId,
146
+ reason: 'workspace_preparation_failed',
147
+ }).catch(() => undefined)
148
+ options.signal?.throwIfAborted()
149
+ throw error
150
+ }
151
+ finally {
152
+ options.signal?.removeEventListener('abort', abort)
153
+ }
154
+ this.workspacePrepared = true
155
+ this.skillCatalog.requestRefresh()
156
+ }
157
+
158
+ async addWorkspaceRepositoryRoot(root: string, options: AgentOperationOptions = {}): Promise<void> {
159
+ this.assertRunning()
160
+ options.signal?.throwIfAborted()
161
+ if (this.workspaceRepositoryRoots.includes(root))
162
+ return
163
+ const repositoryRoots = [...this.workspaceRepositoryRoots, root]
164
+ await this.reconcileInstructionBridge(repositoryRoots)
165
+ options.signal?.throwIfAborted()
166
+ const registeredSkillRoots = workspaceSkillRoots(repositoryRoots)
167
+ await this.client.request('skills/extraRoots/set', { extraRoots: registeredSkillRoots })
168
+ this.workspaceRepositoryRoots = repositoryRoots
169
+ this.registeredSkillRoots = registeredSkillRoots
170
+ this.skillCatalog.setRepositorySkillRoots(catalogRepositorySkillRoots({
171
+ repositoryRoots,
172
+ requestedCwd: this.requestedCwd,
173
+ runtimeCwd: this.metadata.cwd,
174
+ }))
175
+ this.skillCatalog.requestRefresh()
176
+ }
177
+
178
+ requestSkillsRefresh(): void {
179
+ if (!this.stopped && this.workspacePrepared)
180
+ this.skillCatalog.requestRefresh()
181
+ }
182
+
183
+ async requestUserInput(input: UserInputRequestInput): Promise<{ requestId: string }> {
184
+ this.assertRunning()
185
+ const request = this.userInputRequests.create(input)
186
+ this.pushEvent(request.event)
187
+ return { requestId: request.requestId }
188
+ }
189
+
190
+ setActiveTurn(turnId: string | undefined): void {
191
+ this.activeTurnId = turnId
192
+ }
193
+
194
+ clearActiveTurn(): void {
195
+ this.activeTurnId = undefined
196
+ this.interruptingTurnIds.clear()
197
+ }
198
+
199
+ async failActiveTurnAndEndRun(input: CodexRunAuthFailureInput): Promise<void> {
200
+ if (this.stopped)
201
+ return
202
+ this.pushEvent({ type: 'error', payload: { message: 'Codex auth refresh failed.', fatal: false, source: 'provider', error: input.error } })
203
+ if (this.activeTurnId) {
204
+ this.pushEvent({
205
+ type: 'turn.ended',
206
+ turnId: this.activeTurnId,
207
+ actor: { type: 'main', actorId: 'main' },
208
+ payload: { status: 'failed', reason: input.reason, error: input.error },
209
+ })
210
+ this.activeTurnId = undefined
211
+ this.interruptingTurnIds.clear()
212
+ }
213
+ this.pushEvent({ type: 'session.state.changed', payload: { state: 'failed' } })
214
+ this.stopped = true
215
+ // Pinned Codex forwards preceding stdin lines before EOF, preserving the refresh error ordering.
216
+ await this.client.closeInputAndStop()
217
+ this.events.push({ type: 'session.ended', payload: { reason: 'failed', error: input.error } })
218
+ this.events.end()
219
+ }
220
+
221
+ async sendPrompt(command: SendPromptCommand, options: AgentOperationOptions = {}): Promise<AgentPromptAcceptance> {
222
+ this.assertCommandTargetsRun(command)
223
+ this.assertRunning()
224
+ // Released 0.145.0 accepts turn/start during an active turn by silently steering the input
225
+ // and answering a phantom turn id, so the next-vs-active race must be refused from adapter
226
+ // state before any request — nothing is prepared or retained.
227
+ this.deferNextWhileTurnActive(command.mode)
228
+ const prepared = await this.prepareTurn({
229
+ prompt: command.prompt,
230
+ commandId: command.commandId,
231
+ origin: command.origin,
232
+ author: command.author,
233
+ options,
234
+ })
235
+ // A turn/started fact may have reduced during attachment preparation.
236
+ this.deferNextWhileTurnActive(command.mode)
237
+
238
+ const activeTurnId = this.activeTurnId
239
+ if (command.mode === 'next' || !activeTurnId)
240
+ return await this.startPreparedTurn(prepared)
241
+
242
+ const clientUserMessageId = this.rememberPromptCommand(prepared.commandId, prepared.origin)
243
+ let result: ClientRequestResponsesByMethod['turn/steer']
244
+ try {
245
+ result = await this.client.request('turn/steer', {
246
+ threadId: this.metadata.providerSessionId,
247
+ expectedTurnId: activeTurnId,
248
+ input: prepared.input,
249
+ clientUserMessageId,
250
+ })
251
+ }
252
+ catch (error) {
253
+ if (isSteerNotAcceptedResponse(error)) {
254
+ throw new AgentPromptDeferredError({
255
+ message: 'Codex rejected the steer at a turn boundary race.',
256
+ cause: error,
257
+ })
258
+ }
259
+ throw mapTurnTransportLoss(error)
260
+ }
261
+ return { kind: 'turn', turnId: result.turnId, placement: 'steered' }
262
+ }
263
+
264
+ private deferNextWhileTurnActive(mode: SendPromptCommand['mode']): void {
265
+ if (mode !== 'next' || !this.activeTurnId)
266
+ return
267
+ throw new AgentPromptDeferredError({
268
+ message: 'Codex turn is active; a next prompt cannot start a turn.',
269
+ })
270
+ }
271
+
272
+ async respondUserInput(command: RespondUserInputAgentCommand, options: AgentOperationOptions = {}): Promise<AgentUserInputSubmission> {
273
+ this.assertCommandTargetsRun(command)
274
+ this.assertRunning()
275
+ options.signal?.throwIfAborted()
276
+ const resolved = this.userInputRequests.resolve(command)
277
+ if (!resolved)
278
+ return 'already_delivered'
279
+ if (resolved.event)
280
+ this.pushEvent(resolved.event)
281
+ const prepared = await this.prepareTurn({ prompt: resolved.prompt, commandId: command.commandId, options })
282
+ await this.startPreparedTurn(prepared)
283
+ this.userInputRequests.markDelivered(command.requestId)
284
+ return 'submitted'
285
+ }
286
+
287
+ async interrupt(command: InterruptAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
288
+ this.assertCommandTargetsRun(command)
289
+ this.assertRunning()
290
+ options.signal?.throwIfAborted()
291
+ const turnId = command.turnId ?? this.activeTurnId
292
+ if (!turnId)
293
+ return
294
+ // codex never answers a second turn/interrupt for a turn already being interrupted; the duplicate await would hang forever.
295
+ if (this.interruptingTurnIds.has(turnId))
296
+ return
297
+ this.interruptingTurnIds.add(turnId)
298
+ try {
299
+ await this.client.request('turn/interrupt', { threadId: this.metadata.providerSessionId, turnId })
300
+ }
301
+ catch (error) {
302
+ this.interruptingTurnIds.delete(turnId)
303
+ throw mapTurnTransportLoss(error)
304
+ }
305
+ }
306
+
307
+ async stop(command: StopAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
308
+ this.assertCommandTargetsRun(command)
309
+ if (this.stoppingTask) {
310
+ await this.stoppingTask
311
+ return
312
+ }
313
+ if (this.stopped)
314
+ return
315
+ options.signal?.throwIfAborted()
316
+ this.stopped = true
317
+ this.events.push({ type: 'session.state.changed', payload: { state: 'stopping' } })
318
+ this.onStop()
319
+ this.stoppingTask = (async () => {
320
+ await this.client.stop()
321
+ this.events.push({ type: 'session.ended', payload: { reason: 'stopped' } })
322
+ this.events.push({ type: 'session.state.changed', payload: { state: 'stopped' } })
323
+ this.events.end()
324
+ })()
325
+ await this.stoppingTask
326
+ }
327
+
328
+ private async prepareTurn({ prompt, commandId, origin = 'user', author, options }: { prompt: AgentPrompt, commandId: string, origin?: 'user' | 'supervisor', author?: AgentAuthor, options: AgentOperationOptions }): Promise<PreparedCodexTurn> {
329
+ options.signal?.throwIfAborted()
330
+ const mapperInput = { prompt, origin, author, senderContext: options.senderContext }
331
+ const input = options.inputAttachmentPreparation
332
+ ? await prepareCodexUserInput({ ...mapperInput, preparation: options.inputAttachmentPreparation })
333
+ : toCodexUserInput(mapperInput)
334
+ options.signal?.throwIfAborted()
335
+ return { input, commandId, origin }
336
+ }
337
+
338
+ private async startPreparedTurn(prepared: PreparedCodexTurn): Promise<AgentPromptAcceptance> {
339
+ const clientUserMessageId = this.rememberPromptCommand(prepared.commandId, prepared.origin)
340
+ let result: ClientRequestResponsesByMethod['turn/start']
341
+ try {
342
+ result = await this.client.request('turn/start', {
343
+ threadId: this.metadata.providerSessionId,
344
+ input: prepared.input,
345
+ cwd: this.metadata.cwd,
346
+ model: this.metadata.model,
347
+ effort: codexEffort(this.reasoningEffort),
348
+ summary: codexReasoningSummary,
349
+ approvalPolicy: 'never',
350
+ sandboxPolicy: { type: 'dangerFullAccess' },
351
+ clientUserMessageId,
352
+ })
353
+ }
354
+ catch (error) {
355
+ throw mapTurnTransportLoss(error)
356
+ }
357
+ this.activeTurnId = result.turn.id
358
+ return { kind: 'turn', turnId: result.turn.id, placement: 'started' }
359
+ }
360
+
361
+ private rememberPromptCommand(commandId: string, origin: 'user' | 'supervisor'): string {
362
+ const clientUserMessageId = deriveMessageId(commandId)
363
+ this.eventMapperState.promptCommandByClientUserMessageId.set(clientUserMessageId, { commandId, origin })
364
+ return clientUserMessageId
365
+ }
366
+
367
+ private assertRunning(): void {
368
+ if (this.stopped) {
369
+ throw new AgentRunStateError({ message: 'Codex run has stopped.' })
370
+ }
371
+ }
372
+
373
+ private assertCommandTargetsRun(command: { provider: string, sessionId: string, providerSessionId: string }): void {
374
+ if (command.provider !== 'codex' || command.sessionId !== this.metadata.sessionId || command.providerSessionId !== this.metadata.providerSessionId) {
375
+ throw new AgentRunStateError({ message: 'Command does not target this Codex run.' })
376
+ }
377
+ }
378
+ }
379
+
380
+ // Only the proven not-accepted fact may defer a steer: on the pinned runtime every turn/steer
381
+ // -32600 means the input was not accepted. Other codes (-32001 included) are not blanket
382
+ // overload/deferral facts and keep their honest provider error.
383
+ function isSteerNotAcceptedResponse(error: unknown): error is CodexJsonRpcResponseError {
384
+ return error instanceof CodexJsonRpcResponseError && error.method === 'turn/steer' && error.responseErrorCode === -32600
385
+ }
386
+
387
+ // Transport loss on an input-carrying request maps to write-state truth: possible_write means the
388
+ // input may already be in the provider thread (delivery unknown, never blind-retryable);
389
+ // before_write is proven non-delivery and safe to retry. Anything else passes through unchanged.
390
+ function mapTurnTransportLoss(error: unknown): unknown {
391
+ if (!(error instanceof CodexJsonRpcTransportClosedError))
392
+ return error
393
+ if (error.writeState === 'possible_write') {
394
+ return new AgentDeliveryUnknownError({
395
+ message: 'Codex app-server connection was lost after the input may have been written.',
396
+ cause: error,
397
+ })
398
+ }
399
+ return new AgentNotAcceptedError({
400
+ message: 'Codex app-server connection was closed before the input was written.',
401
+ recovery: { kind: 'retry', reason: 'provider_connection_lost' },
402
+ cause: error,
403
+ })
404
+ }
@@ -0,0 +1,158 @@
1
+ import type { AgentSkill } from '../../protocol'
2
+ import type { AgentEventDraft } from '../agent-event-stream'
3
+ import type { CodexJsonRpcClient } from './codex-json-rpc-client'
4
+ import type { ClientRequestResponsesByMethod } from './codex-protocol'
5
+ import { createHash } from 'node:crypto'
6
+ import { realpath } from 'node:fs/promises'
7
+ import { isAbsolute, relative, sep } from 'node:path'
8
+
9
+ type SessionSkillsUpdatedDraft = Extract<AgentEventDraft, { type: 'session.skills.updated' }>
10
+ type ErrorDraft = Extract<AgentEventDraft, { type: 'error' }>
11
+ type SkillsListResponse = ClientRequestResponsesByMethod['skills/list']
12
+
13
+ interface CodexSkillCatalogInput {
14
+ client: CodexJsonRpcClient
15
+ cwd: string
16
+ repositorySkillRoots: readonly string[]
17
+ emitSnapshot: (draft: SessionSkillsUpdatedDraft) => void
18
+ emitError: (draft: ErrorDraft) => void
19
+ }
20
+
21
+ export class CodexSkillCatalog {
22
+ private readonly client: CodexJsonRpcClient
23
+ private readonly cwd: string
24
+ private repositorySkillRoots: readonly string[]
25
+ private readonly emitSnapshot: CodexSkillCatalogInput['emitSnapshot']
26
+ private readonly emitError: CodexSkillCatalogInput['emitError']
27
+ private refreshInFlight = false
28
+ private refreshRequested = false
29
+ private refreshScheduled = false
30
+ private lastSnapshot: string | undefined
31
+
32
+ constructor({ client, cwd, repositorySkillRoots, emitSnapshot, emitError }: CodexSkillCatalogInput) {
33
+ this.client = client
34
+ this.cwd = cwd
35
+ this.repositorySkillRoots = repositorySkillRoots
36
+ this.emitSnapshot = emitSnapshot
37
+ this.emitError = emitError
38
+ }
39
+
40
+ setRepositorySkillRoots(repositorySkillRoots: readonly string[]): void {
41
+ this.repositorySkillRoots = repositorySkillRoots
42
+ }
43
+
44
+ requestRefresh(): void {
45
+ this.refreshRequested = true
46
+ if (this.refreshInFlight || this.refreshScheduled)
47
+ return
48
+
49
+ this.refreshScheduled = true
50
+ const immediate = setImmediate(() => {
51
+ this.refreshScheduled = false
52
+ void this.refreshUntilCurrent()
53
+ })
54
+ immediate.unref?.()
55
+ }
56
+
57
+ private async refreshUntilCurrent(): Promise<void> {
58
+ if (this.refreshInFlight)
59
+ return
60
+
61
+ this.refreshInFlight = true
62
+ try {
63
+ while (this.refreshRequested) {
64
+ this.refreshRequested = false
65
+ await this.refreshWithOneRetry()
66
+ }
67
+ }
68
+ finally {
69
+ this.refreshInFlight = false
70
+ if (this.refreshRequested)
71
+ this.requestRefresh()
72
+ }
73
+ }
74
+
75
+ private async refreshWithOneRetry(): Promise<void> {
76
+ if (await this.refreshOnce())
77
+ return
78
+
79
+ // The force-reloaded retry also satisfies invalidations received during the failed request.
80
+ this.refreshRequested = false
81
+ await this.refreshOnce()
82
+ }
83
+
84
+ private async refreshOnce(): Promise<boolean> {
85
+ try {
86
+ const repositorySkillRoots = await repositorySkillRootAliases(this.repositorySkillRoots)
87
+ const response = await this.client.request('skills/list', {
88
+ cwds: [this.cwd],
89
+ forceReload: true,
90
+ })
91
+ const skills = availableSkills(response, repositorySkillRoots)
92
+ const snapshot = JSON.stringify(skills)
93
+ if (snapshot === this.lastSnapshot)
94
+ return true
95
+
96
+ this.lastSnapshot = snapshot
97
+ this.emitSnapshot({ type: 'session.skills.updated', payload: { skills } })
98
+ return true
99
+ }
100
+ catch {
101
+ this.emitError({
102
+ type: 'error',
103
+ payload: {
104
+ message: 'Codex skills catalog refresh failed.',
105
+ fatal: false,
106
+ source: 'provider',
107
+ },
108
+ })
109
+ return false
110
+ }
111
+ }
112
+ }
113
+
114
+ async function repositorySkillRootAliases(roots: readonly string[]): Promise<string[]> {
115
+ const physicalRoots = (await Promise.allSettled(roots.map(async root => await realpath(root))))
116
+ .flatMap(result => result.status === 'fulfilled' ? [result.value] : [])
117
+ return [...new Set([...roots, ...physicalRoots])]
118
+ }
119
+
120
+ function availableSkills(response: SkillsListResponse, repositorySkillRoots: readonly string[]): AgentSkill[] {
121
+ return response.data.flatMap(entry => entry.skills
122
+ .filter(skill => skill.enabled)
123
+ .map(skill => ({
124
+ id: skillId(skill.path),
125
+ name: skill.name,
126
+ description: skill.description,
127
+ scope: canonicalSkillScope({ providerScope: skill.scope, path: skill.path, repositorySkillRoots }),
128
+ })))
129
+ }
130
+
131
+ function canonicalSkillScope(input: {
132
+ providerScope: SkillsListResponse['data'][number]['skills'][number]['scope']
133
+ path: string
134
+ repositorySkillRoots: readonly string[]
135
+ }): AgentSkill['scope'] {
136
+ // Codex 0.145 labels runtime extra roots as user-scoped even when we registered them for selected repositories.
137
+ if (input.providerScope === 'user' && input.repositorySkillRoots.some(root => isPathInside(root, input.path)))
138
+ return 'repository'
139
+
140
+ switch (input.providerScope) {
141
+ case 'user':
142
+ case 'system':
143
+ case 'admin':
144
+ return input.providerScope
145
+ case 'repo':
146
+ return 'repository'
147
+ }
148
+ }
149
+
150
+ function isPathInside(root: string, candidate: string): boolean {
151
+ const pathFromRoot = relative(root, candidate)
152
+ return pathFromRoot === '' || (!isAbsolute(pathFromRoot) && pathFromRoot !== '..' && !pathFromRoot.startsWith(`..${sep}`))
153
+ }
154
+
155
+ function skillId(path: string): string {
156
+ const digest = createHash('sha256').update(`codex-skill\0${path}`).digest('hex')
157
+ return `skill_${digest}`
158
+ }
@@ -0,0 +1,19 @@
1
+ import { join, relative, resolve } from 'node:path'
2
+
3
+ export function workspaceSkillRoots(repositoryRoots: readonly string[]): string[] {
4
+ return stableRepositoryRoots(repositoryRoots).map(root => join(root, '.agents', 'skills'))
5
+ }
6
+
7
+ export function catalogRepositorySkillRoots(input: {
8
+ repositoryRoots: readonly string[]
9
+ requestedCwd: string
10
+ runtimeCwd: string
11
+ }): string[] {
12
+ const requestedRoots = workspaceSkillRoots(input.repositoryRoots)
13
+ const runtimeRepositoryRoots = input.repositoryRoots.map(root => resolve(input.runtimeCwd, relative(input.requestedCwd, root)))
14
+ return [...new Set([...requestedRoots, ...workspaceSkillRoots(runtimeRepositoryRoots)])]
15
+ }
16
+
17
+ export function stableRepositoryRoots(repositoryRoots: readonly string[]): string[] {
18
+ return [...new Set(repositoryRoots)].sort()
19
+ }
@@ -0,0 +1,55 @@
1
+ import type { JsonValue } from '../../protocol'
2
+ import type { AgentTool, AgentToolContext } from '../agent-tool'
3
+ import type {
4
+ CodexDynamicToolCallParams,
5
+ CodexDynamicToolCallResponse,
6
+ CodexDynamicToolOutputContentItem,
7
+ CodexDynamicToolSpec,
8
+ } from './codex-protocol'
9
+ import { isAppError } from '../../errors'
10
+ import { runAgentTool } from '../agent-tool-runner'
11
+
12
+ const UNEXPECTED_TOOL_FAILURE_MESSAGE = 'The tool could not be executed.'
13
+
14
+ export function toCodexDynamicTools(tools: readonly AgentTool[]): CodexDynamicToolSpec[] {
15
+ return tools.map(runtimeTool => ({
16
+ type: 'function',
17
+ name: runtimeTool.name,
18
+ description: runtimeTool.description,
19
+ inputSchema: runtimeTool.definition().inputSchema,
20
+ }))
21
+ }
22
+
23
+ export async function runCodexDynamicTool(input: {
24
+ tool: AgentTool
25
+ params: CodexDynamicToolCallParams
26
+ context: AgentToolContext
27
+ }): Promise<CodexDynamicToolCallResponse> {
28
+ try {
29
+ const result = await runAgentTool({
30
+ tool: input.tool,
31
+ actor: input.params.threadId === input.context.providerSessionId ? 'main' : 'subagent',
32
+ rawInput: input.params.arguments as JsonValue,
33
+ context: input.context,
34
+ })
35
+
36
+ return {
37
+ success: result.success,
38
+ contentItems: result.content.map(toCodexToolContent),
39
+ }
40
+ }
41
+ catch (error) {
42
+ if (isAppError(error))
43
+ throw error
44
+ return {
45
+ success: false,
46
+ contentItems: [{ type: 'inputText', text: UNEXPECTED_TOOL_FAILURE_MESSAGE }],
47
+ }
48
+ }
49
+ }
50
+
51
+ function toCodexToolContent(content: { type: 'text', text: string } | { type: 'image', data: string, mimeType: string }): CodexDynamicToolOutputContentItem {
52
+ if (content.type === 'text')
53
+ return { type: 'inputText', text: content.text }
54
+ return { type: 'inputImage', imageUrl: `data:${content.mimeType};base64,${content.data}` }
55
+ }