@osdlc/agent-call 0.0.1-stage → 0.4.1

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.
@@ -0,0 +1,314 @@
1
+ import { CONVERSATION_SCHEMA, } from '../conversation.js';
2
+ import { note, TMP_TOKEN } from '../plan.js';
3
+ import { applyPermissionMode, asNumber, asString, buildEffectivePrompt, get, modelFlag, parseJsonLines, tryParseJson, } from './shared.js';
4
+ const MCP_CONFIG_FILE = 'mcp.json';
5
+ /**
6
+ * Claude Code (`claude -p`). The richest surface: permissions, tool rules,
7
+ * system prompts, structured output, sessions, and budget are all native.
8
+ */
9
+ export function claudeAdapter(options = {}) {
10
+ const binary = options.binary ?? 'claude';
11
+ return {
12
+ name: 'claude',
13
+ binary,
14
+ plan(spec) {
15
+ return planClaude(binary, spec);
16
+ },
17
+ parseResult(planned, raw) {
18
+ return parseClaude(planned, raw);
19
+ },
20
+ toConversation(planned, raw) {
21
+ return claudeConversation(planned, raw);
22
+ },
23
+ };
24
+ }
25
+ /**
26
+ * `--resume=<id>`: `--resume` takes an optional value, so the `=` form keeps an
27
+ * id that starts with `-` bound to it.
28
+ *
29
+ * @experimental Part of the launch-argv helpers, which may change in a minor release.
30
+ */
31
+ export function claudeResumeFlag(id) {
32
+ return `--resume=${id}`;
33
+ }
34
+ /** `--effort <level>`: passed through as given, so a level the CLI adds later needs no change here. */
35
+ export function claudeEffortArgv(effort) {
36
+ return ['--effort', effort];
37
+ }
38
+ /**
39
+ * Claude argv for session-only plugins: `--plugin-dir` per dir, then a
40
+ * `--settings` disable map. For callers that build claude's command line
41
+ * themselves.
42
+ *
43
+ * @experimental Part of the launch-argv helpers, which may change in a minor release.
44
+ */
45
+ export function claudePluginArgv(plugins) {
46
+ const argv = plugins.dirs.flatMap((dir) => ['--plugin-dir', dir]);
47
+ if (plugins.disable.length > 0) {
48
+ const enabledPlugins = Object.fromEntries(plugins.disable.map((id) => [id, false]));
49
+ argv.push('--settings', JSON.stringify({ enabledPlugins }));
50
+ }
51
+ return argv;
52
+ }
53
+ function planClaude(binary, spec) {
54
+ const notes = [];
55
+ const tempFiles = [];
56
+ const argv = [binary, '-p'];
57
+ // Prefer the JSON envelope when the caller didn't ask for a specific
58
+ // format: same answer, plus usage/cost/session metadata.
59
+ let format = spec.output?.format ?? 'json';
60
+ if (spec.output?.schema !== undefined && format === 'text') {
61
+ format = 'json';
62
+ note(notes, 'output.format', 'approximated', 'structured output requires the json envelope; text was upgraded to json');
63
+ }
64
+ argv.push('--output-format', format);
65
+ if (format === 'stream-json')
66
+ argv.push('--verbose');
67
+ if (spec.model !== undefined)
68
+ argv.push(...modelFlag(spec.model));
69
+ if (spec.effort !== undefined) {
70
+ argv.push(...claudeEffortArgv(spec.effort));
71
+ note(notes, 'effort', 'native', '--effort');
72
+ }
73
+ if (spec.mcp?.servers !== undefined) {
74
+ // A file, not inline JSON: server `env` holds tokens, and argv is readable
75
+ // by any local user.
76
+ tempFiles.push({
77
+ name: MCP_CONFIG_FILE,
78
+ content: JSON.stringify({ mcpServers: spec.mcp.servers }),
79
+ });
80
+ argv.push('--mcp-config', `${TMP_TOKEN}/${MCP_CONFIG_FILE}`);
81
+ note(notes, 'mcp.servers', 'native', '--mcp-config');
82
+ if (spec.mcp.strict === true)
83
+ argv.push('--strict-mcp-config');
84
+ }
85
+ if (spec.plugins !== undefined) {
86
+ argv.push(...claudePluginArgv(spec.plugins));
87
+ note(notes, 'plugins', 'native', '--plugin-dir / --settings enabledPlugins');
88
+ }
89
+ if (spec.systemPrompt !== undefined) {
90
+ argv.push('--system-prompt', spec.systemPrompt);
91
+ note(notes, 'systemPrompt', 'native', '--system-prompt');
92
+ }
93
+ if (spec.appendSystemPrompt !== undefined) {
94
+ argv.push('--append-system-prompt', spec.appendSystemPrompt);
95
+ note(notes, 'appendSystemPrompt', 'native', '--append-system-prompt');
96
+ }
97
+ const permissions = spec.permissions;
98
+ applyPermissionMode(argv, notes, permissions?.mode, {
99
+ 'read-only': {
100
+ argv: ['--permission-mode', 'plan'],
101
+ support: 'approximated',
102
+ detail: 'read-only mapped to --permission-mode plan (analysis only, no mutations)',
103
+ },
104
+ edits: {
105
+ argv: ['--permission-mode', 'acceptEdits'],
106
+ support: 'native',
107
+ detail: '--permission-mode acceptEdits',
108
+ },
109
+ 'full-auto': {
110
+ argv: ['--permission-mode', 'acceptEdits', '--allowedTools', 'Bash WebFetch WebSearch'],
111
+ support: 'approximated',
112
+ detail: 'full-auto mapped to acceptEdits plus pre-approved Bash/WebFetch/WebSearch (claude has no workspace sandbox)',
113
+ },
114
+ bypass: {
115
+ argv: ['--dangerously-skip-permissions'],
116
+ support: 'native',
117
+ detail: '--dangerously-skip-permissions',
118
+ },
119
+ });
120
+ if (permissions?.allowTools !== undefined && permissions.allowTools.length > 0) {
121
+ argv.push('--allowedTools', permissions.allowTools.join(','));
122
+ note(notes, 'permissions.allowTools', 'native', '--allowedTools');
123
+ }
124
+ if (permissions?.denyTools !== undefined && permissions.denyTools.length > 0) {
125
+ argv.push('--disallowedTools', permissions.denyTools.join(','));
126
+ note(notes, 'permissions.denyTools', 'native', '--disallowedTools');
127
+ }
128
+ for (const dir of spec.context?.dirs ?? []) {
129
+ argv.push('--add-dir', dir);
130
+ }
131
+ if ((spec.context?.dirs ?? []).length > 0) {
132
+ note(notes, 'context.dirs', 'native', '--add-dir');
133
+ }
134
+ if (spec.output?.schema !== undefined) {
135
+ argv.push('--json-schema', JSON.stringify(spec.output.schema));
136
+ note(notes, 'output.schema', 'native', '--json-schema');
137
+ }
138
+ const session = spec.session;
139
+ if (session?.id !== undefined)
140
+ argv.push('--session-id', session.id);
141
+ if (session?.resume !== undefined)
142
+ argv.push(claudeResumeFlag(session.resume));
143
+ if (session?.continueLast === true)
144
+ argv.push('--continue');
145
+ if (session?.persist === false) {
146
+ argv.push('--no-session-persistence');
147
+ note(notes, 'session.persist', 'native', '--no-session-persistence');
148
+ }
149
+ if (spec.budgetUsd !== undefined) {
150
+ argv.push('--max-budget-usd', String(spec.budgetUsd));
151
+ note(notes, 'budgetUsd', 'native', '--max-budget-usd');
152
+ }
153
+ argv.push(...(spec.extraArgs ?? []));
154
+ const stdin = buildEffectivePrompt(spec, { systemNative: true, appendSystemNative: true, schemaNative: true }, notes);
155
+ return {
156
+ agent: 'claude',
157
+ argv,
158
+ stdin,
159
+ env: { ...spec.env },
160
+ cwd: spec.cwd,
161
+ outputFormat: format,
162
+ tempFiles,
163
+ notes,
164
+ spec,
165
+ };
166
+ }
167
+ function parseClaude(planned, raw) {
168
+ if (planned.outputFormat === 'text') {
169
+ return { text: raw.stdout.trim() };
170
+ }
171
+ if (planned.outputFormat === 'stream-json') {
172
+ const events = parseJsonLines(raw.stdout);
173
+ const result = events.find((e) => get(e, 'type') === 'result');
174
+ if (result === undefined) {
175
+ // The stream ended without its terminal envelope (crash, kill, truncated
176
+ // output). Mirrors the `json` arm's missing-envelope guard below; the
177
+ // events are still returned so callers can mine them, and the exit code
178
+ // and stderr stay on the AgentResult.
179
+ return {
180
+ text: '',
181
+ isError: true,
182
+ errorDetail: 'claude --output-format stream-json produced no result event',
183
+ events,
184
+ };
185
+ }
186
+ return {
187
+ ...envelopeFields(result, planned),
188
+ events,
189
+ };
190
+ }
191
+ const envelope = tryParseJson(raw.stdout);
192
+ if (envelope === undefined) {
193
+ return {
194
+ text: raw.stdout.trim(),
195
+ isError: true,
196
+ errorDetail: 'claude --output-format json produced no parseable envelope',
197
+ };
198
+ }
199
+ return envelopeFields(envelope, planned);
200
+ }
201
+ /** Shared between the `json` envelope and the `stream-json` result event. */
202
+ function envelopeFields(envelope, planned) {
203
+ const text = asString(get(envelope, 'result')) ?? '';
204
+ const subtype = asString(get(envelope, 'subtype'));
205
+ const isError = get(envelope, 'is_error') === true || subtype === 'error';
206
+ const parsed = {
207
+ text,
208
+ sessionId: asString(get(envelope, 'session_id')),
209
+ costUsd: asNumber(get(envelope, 'total_cost_usd')),
210
+ usage: claudeUsage(get(envelope, 'usage')),
211
+ stopReason: subtype,
212
+ isError,
213
+ errorDetail: isError ? text || subtype : undefined,
214
+ };
215
+ if (planned.spec.output?.schema !== undefined) {
216
+ parsed.structured = get(envelope, 'structured_output') ?? tryParseJson(text);
217
+ }
218
+ return parsed;
219
+ }
220
+ function claudeUsage(usage) {
221
+ if (usage === null || typeof usage !== 'object')
222
+ return undefined;
223
+ return {
224
+ inputTokens: asNumber(get(usage, 'input_tokens')),
225
+ outputTokens: asNumber(get(usage, 'output_tokens')),
226
+ cacheReadTokens: asNumber(get(usage, 'cache_read_input_tokens')),
227
+ cacheWriteTokens: asNumber(get(usage, 'cache_creation_input_tokens')),
228
+ };
229
+ }
230
+ function claudeConversation(planned, raw) {
231
+ const parsed = parseClaude(planned, raw);
232
+ const messages = [
233
+ { role: 'user', parts: [{ type: 'text', text: planned.stdin ?? '' }] },
234
+ ];
235
+ if (planned.outputFormat === 'stream-json') {
236
+ for (const event of parsed.events ?? []) {
237
+ const type = get(event, 'type');
238
+ if (type !== 'assistant' && type !== 'user')
239
+ continue;
240
+ const content = get(event, 'message', 'content');
241
+ const parts = anthropicContentParts(content);
242
+ if (parts.length > 0) {
243
+ messages.push({ role: type === 'assistant' ? 'assistant' : 'tool', parts });
244
+ }
245
+ }
246
+ }
247
+ else if (parsed.text !== '') {
248
+ messages.push({
249
+ role: 'assistant',
250
+ parts: [{ type: 'text', text: parsed.text }],
251
+ });
252
+ }
253
+ const model = firstAssistantModel(parsed.events ?? []);
254
+ return {
255
+ schema: CONVERSATION_SCHEMA,
256
+ agent: 'claude',
257
+ model,
258
+ sessionId: parsed.sessionId,
259
+ messages,
260
+ usage: parsed.usage,
261
+ costUsd: parsed.costUsd,
262
+ stopReason: parsed.stopReason,
263
+ raw: {
264
+ format: `claude/${planned.outputFormat}`,
265
+ data: planned.outputFormat === 'stream-json'
266
+ ? parsed.events
267
+ : (tryParseJson(raw.stdout) ?? raw.stdout),
268
+ },
269
+ };
270
+ }
271
+ /** Anthropic message content blocks → normalized parts. */
272
+ function anthropicContentParts(content) {
273
+ if (!Array.isArray(content))
274
+ return [];
275
+ const parts = [];
276
+ for (const block of content) {
277
+ const type = get(block, 'type');
278
+ if (type === 'text') {
279
+ const text = asString(get(block, 'text'));
280
+ if (text !== undefined && text !== '')
281
+ parts.push({ type: 'text', text });
282
+ }
283
+ else if (type === 'thinking') {
284
+ const text = asString(get(block, 'thinking'));
285
+ if (text !== undefined)
286
+ parts.push({ type: 'reasoning', text });
287
+ }
288
+ else if (type === 'tool_use') {
289
+ parts.push({
290
+ type: 'tool-call',
291
+ callId: asString(get(block, 'id')),
292
+ name: asString(get(block, 'name')) ?? 'unknown',
293
+ input: get(block, 'input'),
294
+ });
295
+ }
296
+ else if (type === 'tool_result') {
297
+ parts.push({
298
+ type: 'tool-result',
299
+ callId: asString(get(block, 'tool_use_id')),
300
+ output: get(block, 'content'),
301
+ isError: get(block, 'is_error') === true,
302
+ });
303
+ }
304
+ }
305
+ return parts;
306
+ }
307
+ function firstAssistantModel(events) {
308
+ for (const event of events) {
309
+ const model = asString(get(event, 'message', 'model'));
310
+ if (model !== undefined)
311
+ return model;
312
+ }
313
+ return undefined;
314
+ }
@@ -0,0 +1,18 @@
1
+ import type { AgentAdapter } from './types.js';
2
+ /** Options for {@link codexAdapter}. */
3
+ export interface CodexAdapterOptions {
4
+ /** Binary to spawn. Default: the CLI's own name. */
5
+ binary?: string;
6
+ }
7
+ /**
8
+ * OpenAI Codex CLI (`codex exec`). Sandbox modes map cleanly onto the
9
+ * canonical permission postures; structured output is native via
10
+ * `--output-schema`; tool-level rules are not expressible.
11
+ */
12
+ export declare function codexAdapter(options?: CodexAdapterOptions): AgentAdapter;
13
+ /**
14
+ * `-c model_reasoning_effort="<level>"`, quoted as codex's TOML override
15
+ * expects. The level passes through as given; which levels a model takes
16
+ * is the caller's to know (`codex debug models` lists them).
17
+ */
18
+ export declare function codexEffortArgv(effort: string): string[];