@meistrari/agent-core 0.2.0-experimental.0 → 0.2.0-experimental.2

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 (90) hide show
  1. package/README.md +82 -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 +876 -124
  19. package/src/agents/claude/claude-file-change.ts +128 -0
  20. package/src/agents/claude/claude-provider.ts +344 -29
  21. package/src/agents/claude/claude-run.ts +922 -91
  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 +375 -64
  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 +428 -48
  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 +54 -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 +4 -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/steps/contract.ts +4 -1
  72. package/src/steps/projector.ts +17 -1
  73. package/src/supervisor/agent-provider-factory.ts +60 -5
  74. package/src/supervisor/bootstrap-binder.ts +3 -2
  75. package/src/supervisor/index.ts +1 -1
  76. package/src/supervisor/persistence/supervisor-store.ts +22 -8
  77. package/src/supervisor/runtime-handler.ts +30 -2
  78. package/src/supervisor-protocol/agent-event-wrapper.ts +30 -0
  79. package/src/supervisor-protocol/bootstrap.ts +4 -1
  80. package/src/supervisor-protocol/command-body.ts +4 -1
  81. package/src/supervisor-protocol/envelopes/supervisor-to-control-plane.ts +1 -0
  82. package/src/testing/in-memory-runtime-control-plane.ts +118 -12
  83. package/src/testing/index.ts +1 -1
  84. package/src/worker-runtime-client/command-pump.ts +85 -10
  85. package/src/worker-runtime-client/connection-attempt.ts +95 -20
  86. package/src/worker-runtime-client/frame-processor.ts +242 -58
  87. package/src/worker-runtime-client/index.ts +8 -2
  88. package/src/worker-runtime-client/ports.ts +34 -2
  89. package/src/worker-runtime-client/rpc-request-manager.ts +152 -107
  90. package/src/worker-runtime-client/sandbox-connection-runtime.ts +72 -35
@@ -0,0 +1,47 @@
1
+ import type { CodexAuthProvider, CodexAuthRefreshInput, CodexAuthTokenRequest, CodexAuthTokens } from './codex-auth'
2
+ import type { CodexBrowserAuthSource } from './codex-browser-auth-broker'
3
+ import { CodexRunAuth, CodexRunAuthClosedError } from './codex-run-auth'
4
+
5
+ /**
6
+ * A `CodexRunAuth` for a run whose own provider is not Codex (Claude Browser Use). The account is
7
+ * bound only when the first browser connection asks for credentials, so sessions that never use
8
+ * Browser Use never call `codex.auth.start`. A failed binding is discarded rather than cached, so a
9
+ * later browser call can bind again once an account is available. Refresh semantics are exactly
10
+ * those of `CodexRunAuth`.
11
+ */
12
+ export class LazyCodexRunAuth implements CodexBrowserAuthSource {
13
+ private current: CodexRunAuth | undefined
14
+ private closed = false
15
+
16
+ constructor(private readonly provider: CodexAuthProvider) {}
17
+
18
+ async getCurrentTokens(input: CodexAuthTokenRequest): Promise<CodexAuthTokens> {
19
+ if (this.closed)
20
+ throw new CodexRunAuthClosedError()
21
+ const auth = this.current ??= new CodexRunAuth(this.provider)
22
+ try {
23
+ await auth.acquireInitialTokens(input)
24
+ }
25
+ catch (error) {
26
+ if (this.current === auth) {
27
+ this.current = undefined
28
+ auth.close()
29
+ }
30
+ throw error
31
+ }
32
+ return await auth.getCurrentTokens(input)
33
+ }
34
+
35
+ async refreshAuthTokens(input: CodexAuthRefreshInput): Promise<CodexAuthTokens> {
36
+ const auth = this.current
37
+ if (this.closed || !auth)
38
+ throw new CodexRunAuthClosedError()
39
+ return await auth.refreshAuthTokens(input)
40
+ }
41
+
42
+ close(): void {
43
+ this.closed = true
44
+ this.current?.close()
45
+ this.current = undefined
46
+ }
47
+ }
@@ -7,8 +7,13 @@ export * from './agent-runtime-error'
7
7
  export * from './agent-session-events'
8
8
  export * from './agent-tool'
9
9
  export * from './agent-tool-runner'
10
+ export * from './input-attachment.errors'
10
11
  export * from './instructions'
11
12
  export * from './materialized-input-attachment'
12
13
  export * from './message-id'
14
+ export * from './sandbox-environment'
15
+ export { sessionContextText, stripSessionContext } from './session-context'
16
+ export type { SessionContextComposer } from './session-context'
17
+ export * from './subscription-token-refresh'
13
18
  export * from './tools/ping.tool'
14
19
  export * from './user-input-request'
@@ -56,6 +56,19 @@ export function nativeAttachmentLabel(attachment: MaterializedAgentInputAttachme
56
56
  }
57
57
 
58
58
  export function filesystemFallbackLabel(attachment: MaterializedAgentInputAttachment): string {
59
+ if (attachment.mediaType === 'application/gzip' && attachment.filename.endsWith('.tar.gz')) {
60
+ return `Input attachment (gzip-compressed directory archive):\n${JSON.stringify({
61
+ attachmentId: attachment.attachmentId,
62
+ ordinal: attachment.ordinal,
63
+ filename: attachment.filename,
64
+ mediaType: attachment.mediaType,
65
+ byteSize: attachment.byteSize,
66
+ sha256: attachment.sha256,
67
+ path: attachment.localPath,
68
+ archiveFormat: 'tar+gzip',
69
+ usage: 'Extract or read this archive to access the attached directory files.',
70
+ })}`
71
+ }
59
72
  return `Input attachment (filesystem fallback):\n${JSON.stringify({
60
73
  attachmentId: attachment.attachmentId,
61
74
  ordinal: attachment.ordinal,
@@ -0,0 +1,82 @@
1
+ import type { AgentToolContent, JsonObject, JsonValue } from '../protocol'
2
+ import { agentToolContentSchema, jsonValueSchema } from '../protocol'
3
+
4
+ export function ownJsonSlot(value: unknown, key: string): JsonValue | undefined {
5
+ if (typeof value !== 'object' || value === null)
6
+ return undefined
7
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
8
+ if (!descriptor?.enumerable || !('value' in descriptor) || descriptor.value === undefined)
9
+ return undefined
10
+ const parsed = jsonValueSchema.safeParse(descriptor.value)
11
+ return parsed.success ? parsed.data : undefined
12
+ }
13
+
14
+ export function mcpToolContent(value: JsonValue): AgentToolContent {
15
+ const content = publicMcpContent(value)
16
+ if (!isJsonObject(content))
17
+ return jsonToolContent(content)
18
+ const keys = content.type === 'text'
19
+ ? ['type', 'text']
20
+ : content.type === 'image' || content.type === 'audio'
21
+ ? ['type', 'data', 'mimeType']
22
+ : content.type === 'resource_link'
23
+ ? ['type', 'name', 'uri', 'mimeType', 'description', 'title', 'size']
24
+ : content.type === 'resource'
25
+ ? ['type', 'resource']
26
+ : undefined
27
+ if (!keys)
28
+ return jsonToolContent(content)
29
+ let candidate = withPublicExtras(content, [...keys, 'annotations'])
30
+ if (content.type === 'resource' && isJsonObject(content.resource)) {
31
+ candidate = {
32
+ ...candidate,
33
+ resource: withPublicExtras(content.resource, [
34
+ 'uri',
35
+ 'mimeType',
36
+ typeof content.resource.text === 'string' ? 'text' : 'blob',
37
+ ]),
38
+ }
39
+ }
40
+ const parsed = agentToolContentSchema.safeParse(candidate)
41
+ return parsed.success ? parsed.data : jsonToolContent(content)
42
+ }
43
+
44
+ function withPublicExtras(value: JsonObject, knownKeys: string[]): JsonObject {
45
+ const known = Object.entries(value).filter(([key]) => knownKeys.includes(key))
46
+ const extra = Object.entries(value).filter(([key]) => !knownKeys.includes(key))
47
+ return Object.fromEntries(extra.length > 0 ? [...known, ['extras', Object.fromEntries(extra)]] : known)
48
+ }
49
+
50
+ function publicMcpContent(value: JsonValue): JsonValue {
51
+ if (!isJsonObject(value))
52
+ return value
53
+
54
+ const entries: Array<[string, JsonValue]> = []
55
+ for (const key of Object.keys(value)) {
56
+ if (key === '_meta')
57
+ continue
58
+ const entry = value[key]!
59
+ const publicEntry = key === 'resource' && value.type === 'resource' && isJsonObject(entry)
60
+ ? jsonObjectWithoutKey(entry, '_meta')
61
+ : entry
62
+ entries.push([key, publicEntry])
63
+ }
64
+ return Object.fromEntries(entries)
65
+ }
66
+
67
+ function jsonObjectWithoutKey(value: JsonObject, omittedKey: string): JsonObject {
68
+ const entries: Array<[string, JsonValue]> = []
69
+ for (const key of Object.keys(value)) {
70
+ if (key !== omittedKey)
71
+ entries.push([key, value[key]!])
72
+ }
73
+ return Object.fromEntries(entries)
74
+ }
75
+
76
+ export function isJsonObject(value: JsonValue | undefined): value is JsonObject {
77
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
78
+ }
79
+
80
+ export function jsonToolContent(value: JsonValue): AgentToolContent {
81
+ return { type: 'json', value }
82
+ }
@@ -1,5 +1,5 @@
1
1
  import type { AgentEvent, AgentRunMetadata, SendPromptCommand } from '../protocol'
2
- import type { AgentOperationOptions } from './agent-operation'
2
+ import type { AgentOperationOptions, AgentPromptOperationOptions } from './agent-operation'
3
3
  import type { AgentPromptAcceptance, AgentRun } from './agent-run'
4
4
  import { AgentEventStream } from './agent-event-stream'
5
5
  import { AgentNotAcceptedError, AgentPromptDeferredError, AgentRunStateError } from './agent-runtime-error'
@@ -44,8 +44,19 @@ export class ModelRebindingRun implements AgentRun {
44
44
 
45
45
  respondUserInput: AgentRun['respondUserInput'] = async (command, options) => await this.withControl(signal => this.run.respondUserInput(command, { ...options, signal }), options)
46
46
  interrupt: AgentRun['interrupt'] = async (command, options) => await this.withControl(signal => this.run.interrupt(command, { ...options, signal }), options)
47
+ notify: NonNullable<AgentRun['notify']> = async (command, options) => await this.withControl(async (signal) => {
48
+ if (!this.run.notify)
49
+ throw new AgentRunStateError({ message: 'The provider run does not support notifications.' })
50
+ return await this.run.notify(command, { ...options, signal })
51
+ }, options)
47
52
 
48
- async sendPrompt(command: SendPromptCommand, options: AgentOperationOptions = {}): Promise<AgentPromptAcceptance> {
53
+ compactContext: NonNullable<AgentRun['compactContext']> = async (command, options) => await this.withControl(async (signal) => {
54
+ if (!this.run.compactContext)
55
+ throw new AgentRunStateError({ message: 'The provider run does not support context compaction.' })
56
+ return await this.run.compactContext(command, { ...options, signal })
57
+ }, options)
58
+
59
+ async sendPrompt(command: SendPromptCommand, options: AgentPromptOperationOptions = {}): Promise<AgentPromptAcceptance> {
49
60
  if (this.dispatching || this.workspaceChanging || this.controls.size)
50
61
  throw new AgentPromptDeferredError({ message: 'A prompt admission is already in progress.' })
51
62
  const task = this.dispatchPrompt(command, options)
@@ -56,7 +67,7 @@ export class ModelRebindingRun implements AgentRun {
56
67
  finally { this.dispatching = undefined }
57
68
  }
58
69
 
59
- private async dispatchPrompt(command: SendPromptCommand, options: AgentOperationOptions): Promise<AgentPromptAcceptance> {
70
+ private async dispatchPrompt(command: SendPromptCommand, options: AgentPromptOperationOptions): Promise<AgentPromptAcceptance> {
60
71
  if (this.stopped)
61
72
  throw new AgentRunStateError({ message: 'Cannot send a prompt after the run stopped.' })
62
73
  if (command.sessionId !== this.metadata.sessionId || command.providerSessionId !== this.metadata.providerSessionId || command.provider !== this.metadata.provider)
@@ -0,0 +1,202 @@
1
+ import type { AgentProviderId } from '../protocol'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { lstat, open, readFile, rename, unlink } from 'node:fs/promises'
4
+ import { basename, dirname, join } from 'node:path'
5
+ import { AgentNotAcceptedError, AgentProviderRuntimeError } from './agent-runtime-error'
6
+ import { stableRepositoryRoots } from './codex/codex-skill-roots'
7
+
8
+ const managedInstructionsSentinel = '<!-- coding-agent-managed-selected-repository-instructions -->'
9
+
10
+ // Each provider reads its own repository instruction file first and falls back to AGENTS.md,
11
+ // matching Codex's override precedence and Claude Code's default CLAUDE.md-or-AGENTS.md mode.
12
+ const repositoryInstructionFiles = {
13
+ codex: { preferred: 'AGENTS.override.md', referencing: 'AGENTS file' },
14
+ claude: { preferred: 'CLAUDE.md', referencing: 'instruction file' },
15
+ } as const satisfies Record<AgentProviderId, { preferred: string, referencing: string }>
16
+
17
+ type ManagedFileOwnership = 'missing' | 'managed' | 'unowned'
18
+
19
+ /**
20
+ * The runtime-owned discovery guidance each adapter places in its provider's native instruction
21
+ * file: optional product context, the selected repository roots, and how to find each repository's
22
+ * own instructions. Deterministic for a given root set. Nothing is published when there is neither
23
+ * a repository nor product context.
24
+ */
25
+ export function selectedRepositoryInstructions(input: { provider: AgentProviderId, repositoryRoots: readonly string[], sharedContext?: string }): string | undefined {
26
+ const files = repositoryInstructionFiles[input.provider]
27
+ const repositoryRoots = stableRepositoryRoots(input.repositoryRoots)
28
+ if (repositoryRoots.length === 0 && !input.sharedContext)
29
+ return undefined
30
+ const sharedContext = [
31
+ '# Selected repository instructions',
32
+ '',
33
+ ...(input.sharedContext ? [input.sharedContext, ''] : []),
34
+ ]
35
+ if (repositoryRoots.length === 0)
36
+ return sharedContext.join('\n')
37
+
38
+ return [
39
+ ...sharedContext,
40
+ 'Selected repository roots:',
41
+ ...repositoryRoots.map(root => `- ${JSON.stringify(root)}`),
42
+ '',
43
+ 'For each repository root, you must:',
44
+ `- Inspect \`${files.preferred}\` first; if it does not exist, inspect \`AGENTS.md\`.`,
45
+ `- Follow every documentation file referenced by the applicable ${files.referencing}.`,
46
+ `- Before editing nested files, re-check for more-local \`${files.preferred}\` or \`AGENTS.md\` files.`,
47
+ '',
48
+ 'Runtime and developer instructions take precedence over these repository instructions.',
49
+ 'Repository instructions apply only within their own repository.',
50
+ 'If selected repositories contain irreconcilable instructions, report the conflict instead of resolving it by repository input order.',
51
+ '',
52
+ ].join('\n')
53
+ }
54
+
55
+ /**
56
+ * Publishes the selected-repository instructions at `path`, a file the runtime owns only when its
57
+ * first line is the managed sentinel. An unowned regular file, symlink, or non-regular entry is
58
+ * never overwritten: with no selected repository it is left alone, otherwise the operation fails
59
+ * terminally. Filesystem faults are retryable.
60
+ */
61
+ export async function reconcileSelectedRepositoryInstructions(input: {
62
+ provider: AgentProviderId
63
+ path: string
64
+ repositoryRoots: readonly string[]
65
+ sharedContext?: string
66
+ }): Promise<void> {
67
+ let ownership: ManagedFileOwnership
68
+ try {
69
+ ownership = await readManagedFileOwnership(input.path)
70
+ }
71
+ catch (error) {
72
+ if (!(error instanceof AgentProviderRuntimeError))
73
+ throw error
74
+ throw repositoryInstructionsUnavailable({ recoveryKind: 'retry', cause: error })
75
+ }
76
+
77
+ const content = selectedRepositoryInstructions(input)
78
+ if (content === undefined) {
79
+ // Only a stale managed file is removed; an unowned one is never touched.
80
+ if (ownership === 'managed')
81
+ await removeManagedFile(input.path)
82
+ return
83
+ }
84
+
85
+ if (ownership === 'unowned') {
86
+ if (input.repositoryRoots.length === 0)
87
+ return
88
+ const cause = new AgentProviderRuntimeError({
89
+ message: `Refusing to overwrite ${basename(input.path)}: it is not owned by the Coding Agent runtime.`,
90
+ details: { instructionBridgePath: input.path },
91
+ retryable: false,
92
+ })
93
+ throw repositoryInstructionsUnavailable({ recoveryKind: 'terminal', cause })
94
+ }
95
+
96
+ try {
97
+ await writeManagedFileAtomically({
98
+ path: input.path,
99
+ content: `${managedInstructionsSentinel}\n${content}`,
100
+ })
101
+ }
102
+ catch (error) {
103
+ if (!(error instanceof AgentProviderRuntimeError))
104
+ throw error
105
+ throw repositoryInstructionsUnavailable({ recoveryKind: 'retry', cause: error })
106
+ }
107
+ }
108
+
109
+ export function repositoryInstructionsUnavailable(input: {
110
+ recoveryKind: 'retry' | 'terminal'
111
+ cause: AgentProviderRuntimeError
112
+ }): AgentNotAcceptedError {
113
+ return new AgentNotAcceptedError({
114
+ message: 'Selected repository instructions are unavailable.',
115
+ recovery: { kind: input.recoveryKind, reason: 'repository_instructions_unavailable' },
116
+ cause: input.cause,
117
+ })
118
+ }
119
+
120
+ async function removeManagedFile(path: string): Promise<void> {
121
+ try {
122
+ await unlink(path)
123
+ }
124
+ catch (error) {
125
+ if (isNodeError(error) && error.code === 'ENOENT')
126
+ return
127
+ const cause = new AgentProviderRuntimeError({
128
+ message: 'Failed to remove the stale selected-repository instruction bridge.',
129
+ details: { instructionBridgePath: path },
130
+ retryable: true,
131
+ cause: error,
132
+ })
133
+ throw repositoryInstructionsUnavailable({ recoveryKind: 'retry', cause })
134
+ }
135
+ }
136
+
137
+ async function readManagedFileOwnership(path: string): Promise<ManagedFileOwnership> {
138
+ let entry
139
+ try {
140
+ entry = await lstat(path)
141
+ }
142
+ catch (error) {
143
+ if (isNodeError(error) && error.code === 'ENOENT')
144
+ return 'missing'
145
+ throw new AgentProviderRuntimeError({
146
+ message: 'Failed to inspect the selected-repository instruction bridge.',
147
+ details: { instructionBridgePath: path },
148
+ retryable: true,
149
+ cause: error,
150
+ })
151
+ }
152
+
153
+ if (!entry.isFile() || entry.isSymbolicLink())
154
+ return 'unowned'
155
+
156
+ let content: string
157
+ try {
158
+ content = await readFile(path, 'utf8')
159
+ }
160
+ catch (error) {
161
+ throw new AgentProviderRuntimeError({
162
+ message: 'Failed to inspect the selected-repository instruction bridge.',
163
+ details: { instructionBridgePath: path },
164
+ retryable: true,
165
+ cause: error,
166
+ })
167
+ }
168
+
169
+ return firstLine(content) === managedInstructionsSentinel ? 'managed' : 'unowned'
170
+ }
171
+
172
+ async function writeManagedFileAtomically(input: { path: string, content: string }): Promise<void> {
173
+ const tempPath = join(dirname(input.path), `.${basename(input.path)}.${process.pid}.${randomUUID()}.tmp`)
174
+ let tempFile: Awaited<ReturnType<typeof open>> | undefined
175
+ try {
176
+ tempFile = await open(tempPath, 'wx', 0o640)
177
+ await tempFile.chmod(0o640)
178
+ await tempFile.writeFile(input.content, 'utf8')
179
+ await tempFile.close()
180
+ tempFile = undefined
181
+ await rename(tempPath, input.path)
182
+ }
183
+ catch (error) {
184
+ await tempFile?.close().catch(() => undefined)
185
+ await unlink(tempPath).catch(() => undefined)
186
+ throw new AgentProviderRuntimeError({
187
+ message: 'Failed to publish the selected-repository instruction bridge.',
188
+ details: { instructionBridgePath: input.path },
189
+ retryable: true,
190
+ cause: error,
191
+ })
192
+ }
193
+ }
194
+
195
+ function firstLine(content: string): string {
196
+ const lineEnd = content.indexOf('\n')
197
+ return lineEnd === -1 ? content : content.slice(0, lineEnd)
198
+ }
199
+
200
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
201
+ return error instanceof Error && 'code' in error
202
+ }
@@ -0,0 +1,41 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ /** Product text for a session's web page, delivered once inside the session-context block. */
4
+ export type SessionContextComposer = (input: { sessionWebUrl: string }) => string
5
+
6
+ const openTag = '<coding-agent-session-context>'
7
+ const closeTag = '</coding-agent-session-context>'
8
+ const sessionContextPattern = /^<coding-agent-session-context>[\s\S]*?<\/coding-agent-session-context>\n*/
9
+
10
+ /**
11
+ * The per-session paragraph (session page URL and MCP access recovery) is delivered once, in the
12
+ * session's first user message, instead of the shared instructions, so the instruction block stays
13
+ * byte-identical across sessions and the provider prompt cache can reuse it.
14
+ */
15
+ export function sessionContextText(compose: SessionContextComposer, sessionWebUrl: string): string {
16
+ return `${openTag}\n${compose({ sessionWebUrl })}\n${closeTag}`
17
+ }
18
+
19
+ /** Removes the leading session-context block from echoed user text. */
20
+ export function stripSessionContext(text: string): string {
21
+ return text.replace(sessionContextPattern, '')
22
+ }
23
+
24
+ /**
25
+ * The session context a run still has to deliver. A new session owes it to its first user message;
26
+ * a resumed one owes it only when its transcript lacks it (the first message may not have been
27
+ * delivered before the process stopped).
28
+ */
29
+ export async function pendingSessionContext(input: {
30
+ compose: SessionContextComposer | undefined
31
+ sessionWebUrl: string | undefined
32
+ resumedTranscriptPath: string | undefined
33
+ }): Promise<string | undefined> {
34
+ if (!input.compose || !input.sessionWebUrl)
35
+ return undefined
36
+ const text = sessionContextText(input.compose, input.sessionWebUrl)
37
+ if (input.resumedTranscriptPath === undefined)
38
+ return text
39
+ const transcript = await readFile(input.resumedTranscriptPath, 'utf8').catch(() => '')
40
+ return transcript.includes(input.sessionWebUrl) ? undefined : text
41
+ }
@@ -0,0 +1,73 @@
1
+ export interface SubscriptionTokenIdentity {
2
+ accessTokenGeneration: number
3
+ accessTokenFingerprint: string
4
+ }
5
+
6
+ export interface RejectedSubscriptionToken {
7
+ rejectedTokenGeneration: number
8
+ rejectedTokenFingerprint: string
9
+ }
10
+
11
+ interface SubscriptionTokenRefreshInput<Tokens extends SubscriptionTokenIdentity> {
12
+ /** The installed tokens a rejection is compared against. */
13
+ installed: () => Promise<Tokens>
14
+ /** The installed tokens after the central refresh returns; throws when the holder has closed. */
15
+ current: () => Tokens
16
+ install: (tokens: Tokens) => void
17
+ refresh: (rejected: RejectedSubscriptionToken) => Promise<Tokens>
18
+ accountId: (tokens: Tokens) => string
19
+ conflictingIdentity: () => Error
20
+ }
21
+
22
+ /**
23
+ * Central subscription-token refresh shared by Codex and Claude credential holders. Concurrent
24
+ * rejections of one token share one refresh; decisions are serialized across accounts because
25
+ * generations are not comparable between them. A rejection of an already-replaced token receives
26
+ * the replacement instead of a new refresh. A replacement is installed when it switches account or
27
+ * does not move the generation backwards; the same generation with a different fingerprint is a
28
+ * conflicting identity.
29
+ */
30
+ export class SubscriptionTokenRefresh<Tokens extends SubscriptionTokenIdentity> {
31
+ private readonly pending = new Map<string, Promise<Tokens>>()
32
+ private queue: Promise<void> = Promise.resolve()
33
+
34
+ constructor(private readonly input: SubscriptionTokenRefreshInput<Tokens>) {}
35
+
36
+ async refresh(rejected: RejectedSubscriptionToken): Promise<Tokens> {
37
+ const key = `${rejected.rejectedTokenGeneration}:${rejected.rejectedTokenFingerprint}`
38
+ const pending = this.pending.get(key)
39
+ if (pending)
40
+ return await pending
41
+ const refresh = this.queue.then(async () => await this.replace(rejected))
42
+ this.queue = refresh.then(() => undefined, () => undefined)
43
+ this.pending.set(key, refresh)
44
+ try {
45
+ return await refresh
46
+ }
47
+ finally {
48
+ this.pending.delete(key)
49
+ }
50
+ }
51
+
52
+ clear(): void {
53
+ this.pending.clear()
54
+ }
55
+
56
+ private async replace(rejected: RejectedSubscriptionToken): Promise<Tokens> {
57
+ const installed = await this.input.installed()
58
+ if (rejected.rejectedTokenGeneration !== installed.accessTokenGeneration
59
+ || rejected.rejectedTokenFingerprint !== installed.accessTokenFingerprint) {
60
+ return installed
61
+ }
62
+ const tokens = await this.input.refresh(rejected)
63
+ const current = this.input.current()
64
+ const sameAccount = this.input.accountId(tokens) === this.input.accountId(current)
65
+ if (sameAccount && tokens.accessTokenGeneration === current.accessTokenGeneration && tokens.accessTokenFingerprint !== current.accessTokenFingerprint)
66
+ throw this.input.conflictingIdentity()
67
+ if (!sameAccount || tokens.accessTokenGeneration >= current.accessTokenGeneration) {
68
+ this.input.install(tokens)
69
+ return tokens
70
+ }
71
+ return current
72
+ }
73
+ }
@@ -142,7 +142,11 @@ export class UserInputRequestStore {
142
142
  }
143
143
 
144
144
  shouldHoldIdle(): boolean {
145
- return [...this.requests.values()].some(request => request.status === 'pending')
145
+ return this.hasUnresolvedUserInput()
146
+ }
147
+
148
+ hasUnresolvedUserInput(): boolean {
149
+ return [...this.requests.values()].some(request => request.status === 'pending' || request.status === 'answered')
146
150
  }
147
151
 
148
152
  resolve(command: RespondUserInputAgentCommand): ResolvedUserInputRequest | null {
@@ -165,10 +169,22 @@ export class UserInputRequestStore {
165
169
  return null
166
170
 
167
171
  if (record.status === 'answered') {
168
- assertSameAnswers(record, command.answers)
172
+ const existingAnswers = record.answers
173
+ if (!existingAnswers) {
174
+ throw new AgentRunStateError({
175
+ message: `User input request ${record.requestId} is answered without stored answers.`,
176
+ details: { requestId: record.requestId },
177
+ })
178
+ }
179
+ if (!sameAnswerValues(existingAnswers, command.answers)) {
180
+ throw new AgentRunStateError({
181
+ message: `User input request ${record.requestId} was already answered with different answers.`,
182
+ details: { requestId: record.requestId },
183
+ })
184
+ }
169
185
  return {
170
186
  record,
171
- prompt: promptForResolvedUserInput(record, record.answers),
187
+ prompt: promptForResolvedUserInput(record, existingAnswers),
172
188
  }
173
189
  }
174
190
 
@@ -399,23 +415,6 @@ function validateAnswers(record: UserInputRequestRecord, answers: AgentUserInput
399
415
  }
400
416
  }
401
417
 
402
- function assertSameAnswers(record: UserInputRequestRecord, answers: AgentUserInputAnswers): asserts record is UserInputRequestRecord & { answers: AgentUserInputAnswers } {
403
- const existing = record.answers
404
- if (!existing) {
405
- throw new AgentRunStateError({
406
- message: `User input request ${record.requestId} is answered without stored answers.`,
407
- details: { requestId: record.requestId },
408
- })
409
- }
410
-
411
- if (!sameAnswerValues(existing, answers)) {
412
- throw new AgentRunStateError({
413
- message: `User input request ${record.requestId} was already answered with different answers.`,
414
- details: { requestId: record.requestId },
415
- })
416
- }
417
- }
418
-
419
418
  function sameAnswerValues(left: AgentUserInputAnswers, right: AgentUserInputAnswers): boolean {
420
419
  return stableJson(left) === stableJson(right)
421
420
  }
@@ -12,5 +12,5 @@ export { InfrastructureError } from './infrastructure-error'
12
12
  export type { InfrastructureErrorInput } from './infrastructure-error'
13
13
  export { ProviderError } from './provider-error'
14
14
  export type { ProviderErrorInput } from './provider-error'
15
- export { UnprocessableEntityError, ValidationError } from './validation-error'
15
+ export { PayloadTooLargeError, UnprocessableEntityError, ValidationError } from './validation-error'
16
16
  export type { ValidationErrorInput } from './validation-error'
@@ -17,3 +17,9 @@ export class UnprocessableEntityError extends ValidationError {
17
17
  super(input)
18
18
  }
19
19
  }
20
+
21
+ export class PayloadTooLargeError extends ValidationError {
22
+ constructor(input: ValidationErrorInput) {
23
+ super(input)
24
+ }
25
+ }