@ahpd/agent-claude 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.
package/src/claude.ts ADDED
@@ -0,0 +1,353 @@
1
+ import { probe } from './probe.js';
2
+ import { serversFor } from './mcp.js';
3
+ import { createSession, EFFORT_LABELS, EFFORTS } from './session.js';
4
+ import { turnsOf } from './transcript.js';
5
+ import { catalogue } from './catalog.js';
6
+ import { within } from '@ahpd/server';
7
+ import type { Agent, Bag, Start } from '@ahpd/server';
8
+
9
+ /**
10
+ * The resource a token for this backend is for.
11
+ *
12
+ * Named once, because it is the identifier a client must send back verbatim:
13
+ * the protocol says `authenticate`'s `resource` MUST match one the server
14
+ * advertised, so this string appearing twice with a typo between them is a
15
+ * token nothing will accept.
16
+ */
17
+ const ANTHROPIC = 'https://api.anthropic.com';
18
+
19
+ /**
20
+ * Claude Code, as an agent backend.
21
+ *
22
+ * Everything the host would otherwise have to know about one particular
23
+ * harness: which settings it takes, where its past sessions are kept, and how
24
+ * to start one. The host asks through `Agent` and imports none of this.
25
+ */
26
+
27
+ /** How to build the Claude backend. */
28
+ export interface ClaudeOptions {
29
+ /**
30
+ * The directories it will work in, and the ones it lists.
31
+ *
32
+ * The first is where a session goes when the client names none. A client
33
+ * may name any of the others and nothing else: a host that ran the agent
34
+ * wherever it was told is one anybody who can reach the port can point at
35
+ * any directory on the machine.
36
+ *
37
+ * This is also the catalogue's scope, so a directory left out is one whose
38
+ * sessions are neither listed nor openable.
39
+ */
40
+ paths: string[];
41
+ /** The id clients name. `claude` unless something else already is. */
42
+ provider?: string;
43
+ }
44
+
45
+ /** Claude Code on one or more directories, ready to be handed to `createHost`. */
46
+ export function claude(options: ClaudeOptions): Agent {
47
+ const dirs = options.paths;
48
+ const dir = dirs[0];
49
+ if (dir === undefined)
50
+ throw new Error('claude() needs at least one directory to work in.');
51
+
52
+ /**
53
+ * Which directory a session goes in.
54
+ *
55
+ * Named, or the first. Anything else is refused rather than quietly
56
+ * replaced - a directory accepted and then ignored is a session running
57
+ * somewhere nobody asked for, with nothing on screen to say so.
58
+ */
59
+ const workingDirectory = (asked?: string): string => {
60
+ if (asked === undefined)
61
+ return dir;
62
+ /*
63
+ * Under a served directory, not equal to one.
64
+ *
65
+ * This compared for equality, so a host told to serve `/home/you` served
66
+ * that one directory and refused every project inside it - which is the
67
+ * only kind of directory anybody opens. An editor asks for its workspace
68
+ * folder, and that is never the path somebody passed to `--path`.
69
+ */
70
+ if (!dirs.some((served) => within(served, asked))) {
71
+ throw new Error(
72
+ `This host does not serve ${asked}. It serves ${dirs.join(', ')}.`,
73
+ );
74
+ }
75
+ // What was asked for, not the root it sits under: the session runs where
76
+ // the client said.
77
+ return asked;
78
+ };
79
+
80
+ /*
81
+ * What the probe learned about output styles.
82
+ *
83
+ * The schema is otherwise fixed, but this one property's choices belong to
84
+ * the harness rather than to the protocol - a person's own styles live in
85
+ * their settings - so it is learned once at startup, the way models are,
86
+ * and the control is simply absent until it is known.
87
+ */
88
+ let styles: string[] = [];
89
+
90
+ /**
91
+ * Whether the models this harness offers carry effort controls of their own.
92
+ *
93
+ * A model's `configSchema` and the session-wide `effortLevel` reach the same
94
+ * setting, and a client draws both - so a person is shown two effort
95
+ * controls, on two different values, for one thing. The model's is the
96
+ * truthful one: it lists what that model actually supports, where the
97
+ * session-wide key lists all five whatever is chosen. So this backend offers
98
+ * the session key only while there is nothing better.
99
+ */
100
+ let perModelEffort = false;
101
+ let style: string | undefined;
102
+
103
+ /**
104
+ * What a session can be told to do differently.
105
+ *
106
+ * One schema, used by `resolveSessionConfig` (before a session exists) and
107
+ * by every session's own state (after one does). Two copies would drift, and
108
+ * the composer would offer one set of controls on the new-session screen and
109
+ * a different set the moment a session opened.
110
+ *
111
+ * `sessionMutable` is what each row turns on: the permission mode, the model
112
+ * and the effort level are things the CLI takes on a *running* session.
113
+ * `thinking` is fixed when the query is built, so offering it live would be
114
+ * a switch that flips back.
115
+ */
116
+ const schema = (): Bag => ({
117
+ // A JSON Schema object, and it has to say so: `type` is required, and a
118
+ // schema without it matches nothing a client validates.
119
+ type: 'object',
120
+ properties: {
121
+ /*
122
+ * One axis, and the CLI's own five values.
123
+ *
124
+ * The protocol's config schema is generic and a backend advertises what
125
+ * it has - VS Code's own hosts advertise different properties for
126
+ * Copilot and for Claude, and a client draws whatever it is given. Its
127
+ * Claude host says why in as many words: it collapses the platform's
128
+ * `autoApprove` x `mode` two-axis surface onto one `permissionMode`
129
+ * matching the SDK's enum, and *omits* `autoApprove`, `mode`,
130
+ * `isolation` and `branch` deliberately, because the pickers key off
131
+ * property names and omitting them suppresses a mode and branch UI that
132
+ * would not mean anything here.
133
+ *
134
+ * The wording is that host's too, so one session reads the same however
135
+ * it is opened. `auto` was missing here and is a real mode the CLI
136
+ * takes: the agent deciding, per call, whether it needs to ask.
137
+ */
138
+ permissionMode: {
139
+ scope: 'session',
140
+ type: 'string',
141
+ title: 'Approvals',
142
+ description: 'How the agent handles tool approvals.',
143
+ enum: ['default', 'acceptEdits', 'plan', 'auto', 'bypassPermissions'],
144
+ enumLabels: [
145
+ 'Ask Before Edits',
146
+ 'Edit Automatically',
147
+ 'Plan Mode',
148
+ 'Auto Mode',
149
+ 'Bypass Permissions',
150
+ ],
151
+ enumDescriptions: [
152
+ 'Asks before editing files.',
153
+ 'Edits files without asking, and asks before using other tools.',
154
+ 'Creates a plan before making changes.',
155
+ 'Decides whether to ask for each tool operation.',
156
+ 'Runs all tools without asking.',
157
+ ],
158
+ default: 'default',
159
+ sessionMutable: true,
160
+ },
161
+ /*
162
+ * The model is not a config property.
163
+ *
164
+ * A session has no model; each message has one. The choices are carried
165
+ * on the agent (`RootState.agents[].models`) and the choice on the turn.
166
+ * `session/configChanged` with a `model` key is still honoured, but the
167
+ * model is not advertised here as a control of its own.
168
+ */
169
+ ...(perModelEffort ? {} : {
170
+ effortLevel: {
171
+ scope: 'chat',
172
+ type: 'string',
173
+ title: 'Effort',
174
+ description: 'How hard it thinks before answering.',
175
+ enum: [...EFFORTS],
176
+ enumLabels: EFFORTS.map((one) => EFFORT_LABELS[one]),
177
+ default: 'high',
178
+ sessionMutable: true,
179
+ },
180
+ }),
181
+ // Learned, so absent until the probe has answered and absent for good
182
+ // on a harness that has no styles.
183
+ ...(styles.length > 0
184
+ ? {
185
+ outputStyle: {
186
+ scope: 'session',
187
+ type: 'string',
188
+ title: 'Output style',
189
+ description: 'The voice it answers in.',
190
+ enum: styles,
191
+ enumLabels: styles.map((name) => name.charAt(0).toUpperCase() + name.slice(1)),
192
+ ...(style !== undefined ? { default: style } : {}),
193
+ sessionMutable: true,
194
+ },
195
+ }
196
+ : {}),
197
+ thinking: {
198
+ type: 'string',
199
+ title: 'Thinking',
200
+ description: 'Fixed when the session is created.',
201
+ enum: ['adaptive', 'disabled'],
202
+ enumLabels: ['Adaptive', 'Off'],
203
+ enumDescriptions: ['The agent decides when to think', 'No extended thinking'],
204
+ default: 'adaptive',
205
+ sessionMutable: false,
206
+ },
207
+ /*
208
+ * Per-tool allow and deny, which is the slope the mode above is a cliff.
209
+ *
210
+ * A platform key rather than one of this backend's invention: it is what
211
+ * the reference client's permission picker writes when somebody approves
212
+ * a tool "in this session", and its own Claude host advertises it
213
+ * unchanged because the SDK takes `allowedTools` / `disallowedTools`
214
+ * natively. Without it the only way to stop being asked about the one
215
+ * command you trust is `bypassPermissions`, which stops asking about
216
+ * everything.
217
+ *
218
+ * An object, and the first config value here that is not a string. The
219
+ * protocol declares the bag `Record<string, unknown>`; this host used to
220
+ * declare it `Record<string, string>`, which is why nothing of this
221
+ * shape could be carried at all.
222
+ */
223
+ permissions: {
224
+ scope: 'session',
225
+ type: 'object',
226
+ title: 'Permissions',
227
+ description: 'Per-tool session permissions. Updated when a tool is approved for this session.',
228
+ properties: {
229
+ allow: { type: 'array', title: 'Allowed tools', items: { type: 'string', title: 'Tool name' } },
230
+ deny: { type: 'array', title: 'Denied tools', items: { type: 'string', title: 'Tool name' } },
231
+ },
232
+ default: { allow: [], deny: [] },
233
+ // Unlike `thinking`, this one really can move on a running session:
234
+ // the SDK takes the lists when the query is built, and `canUseTool`
235
+ // is where this host already sits between the agent and the person.
236
+ sessionMutable: true,
237
+ },
238
+ },
239
+ });
240
+
241
+ const defaults = (): Record<string, unknown> => ({
242
+ permissionMode: 'default',
243
+ // Beside its schema or not at all: a value with no property to draw it is
244
+ // a control a client cannot show and cannot change.
245
+ ...(perModelEffort ? {} : { effortLevel: 'high' }),
246
+ thinking: 'adaptive',
247
+ permissions: { allow: [], deny: [] },
248
+ ...(style !== undefined ? { outputStyle: style } : {}),
249
+ });
250
+
251
+ return {
252
+ provider: options.provider ?? 'claude',
253
+ displayName: 'Claude Code',
254
+ // Both, because the SDK resumes at a named prompt: `resumeSessionAt` with
255
+ // `forkSession` continues from a turn under a new id, and a side chat is
256
+ // an unresumed session handed what that turn said.
257
+ chats: { fork: true, sideChat: true },
258
+ // The SDK takes `additionalDirectories` at startup, so a session works in
259
+ // as many as it was given; the first is the process root and is fixed.
260
+ multipleDirectories: true,
261
+ description: `The Claude Agent SDK, on ${dirs.join(', ')}`,
262
+ schema,
263
+ defaults,
264
+
265
+ directories: () => [...dirs],
266
+
267
+ // The styles are kept as well as handed on: `schema()` is asked before any
268
+ // session exists, and it can only offer what has already been learned.
269
+ probe: async () => {
270
+ const offered = await probe(dir);
271
+ styles = offered.outputStyles ?? [];
272
+ style = offered.outputStyle;
273
+ perModelEffort = offered.models.some((model) => model.configSchema !== undefined);
274
+ return offered;
275
+ },
276
+
277
+ // Every directory it serves, as one list. A session is listed by the
278
+ // catalogue of the directory it ran in, and a host serving several has
279
+ // one catalogue.
280
+ list: async () => (await Promise.all(dirs.map((served) => catalogue(served)))).flat(),
281
+
282
+ // Whichever directory holds it. The transcript reader wants the one the
283
+ // session ran in, and only its own catalogue knows which that was.
284
+ transcript: async (id) => {
285
+ for (const served of dirs) {
286
+ const rows = await catalogue(served).catch(() => []);
287
+ if (rows.some((row) => row.id === id))
288
+ return turnsOf(id, served);
289
+ }
290
+ return undefined;
291
+ },
292
+
293
+ /*
294
+ * The one resource this backend can be given a token for.
295
+ *
296
+ * `required: false`, and that is the honest declaration rather than the
297
+ * lenient one: this daemon runs as whoever started it and inherits their
298
+ * `claude login` or `ANTHROPIC_API_KEY`, so it works with nothing pushed
299
+ * at all. Saying `required: true` would refuse clients that would
300
+ * otherwise be perfectly able to open a session.
301
+ */
302
+ protectedResources: [{
303
+ resource: ANTHROPIC,
304
+ resource_name: 'Anthropic API',
305
+ authorization_servers: ['https://console.anthropic.com'],
306
+ required: false,
307
+ }],
308
+
309
+ create: (start: Start) => createSession({
310
+ uri: start.uri,
311
+ chatUri: start.chatUri,
312
+ cwd: workingDirectory(start.workingDirectory),
313
+ /*
314
+ * The MCP servers, declared by this host rather than found by the CLI.
315
+ *
316
+ * The same files the CLI reads - `.mcp.json` beside the project and
317
+ * `mcpServers` in `~/.claude.json` - handed to the SDK so they are
318
+ * *its* servers. That is what makes a token a client signed in with
319
+ * applicable: `setMcpServers` re-declares only what the SDK was given.
320
+ */
321
+ mcpServers: serversFor([workingDirectory(start.workingDirectory), ...(start.additional ?? [])]),
322
+ // Each one checked the way the first is: a directory this host does not
323
+ // serve is not one an agent may be pointed at, however it arrived.
324
+ ...(start.additional && start.additional.length > 0
325
+ ? { additional: start.additional.map((one) => workingDirectory(one)) }
326
+ : {}),
327
+ // The host's own tools, offered to the model beside this backend's.
328
+ ...(start.tools && start.tools.length > 0 ? { tools: start.tools } : {}),
329
+ settings: start.settings,
330
+ schema: start.schema,
331
+ emit: start.emit,
332
+ ...(start.seedCustomizations ? { seedCustomizations: start.seedCustomizations } : {}),
333
+ ...(start.resume !== undefined ? { resume: start.resume } : {}),
334
+ ...(start.forkAt !== undefined ? { forkAt: start.forkAt } : {}),
335
+ ...(start.rewindAt !== undefined ? { rewindAt: start.rewindAt } : {}),
336
+ ...(start.context !== undefined ? { context: start.context } : {}),
337
+ ...(start.seed ? { seed: start.seed } : {}),
338
+ ...(start.onFileEdit ? { onFileEdit: start.onFileEdit } : {}),
339
+ ...(start.onHandshake ? { onHandshake: start.onHandshake } : {}),
340
+ /*
341
+ * A pushed token, as the variable the CLI reads.
342
+ *
343
+ * Which variable that is, is this file's business and not the host's:
344
+ * the host knows a token belongs to `https://api.anthropic.com` and
345
+ * stops there, which is what keeps `createHost` the protocol and
346
+ * nothing else.
347
+ */
348
+ ...(start.credentials?.[ANTHROPIC]
349
+ ? { env: { ANTHROPIC_API_KEY: start.credentials[ANTHROPIC] } }
350
+ : {}),
351
+ }),
352
+ };
353
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The Claude backend, as an `Agent` this host can be handed.
3
+ *
4
+ * One implementation of the contract `@ahpd/server` declares, and the only
5
+ * thing in either package that knows what the Claude harness is: it starts the
6
+ * agent SDK, translates its message stream into the state actions the protocol
7
+ * describes, and reads a transcript somebody else's session left on disk.
8
+ *
9
+ * A host that wants a different harness registers a different `Agent` and
10
+ * never loads this. A host that wants both registers both - `createHost` takes
11
+ * a list, and cannot tell one from another.
12
+ */
13
+
14
+ export { catalogue } from './catalog.js';
15
+ export { claude } from './claude.js';
16
+ export type { ClaudeOptions } from './claude.js';
17
+ export { createSession, EFFORTS, EFFORT_LABELS } from './session.js';
18
+ export type { Published } from './session.js';
19
+ export { probe } from './probe.js';
20
+ export { turnsOf } from './transcript.js';
21
+ export { protectedResource, urlOf } from './mcp.js';
package/src/mcp.ts ADDED
@@ -0,0 +1,75 @@
1
+ /** The MCP servers a session runs with, read from the files the CLI reads. */
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import type { Bag } from '@ahpd/server';
7
+
8
+ /**
9
+ * Every MCP server configured for a directory, by name.
10
+ *
11
+ * The CLI finds these itself and this host normally leaves it to. It reads
12
+ * them here for one reason: a server the *SDK* was given is one
13
+ * `setMcpServers` can re-declare, and a server that came from a settings file
14
+ * is not - so a token a client signs in with has nowhere to go unless this
15
+ * host owns the declaration. The files are the CLI's own: `.mcp.json` beside
16
+ * the project, and `mcpServers` in `~/.claude.json`.
17
+ *
18
+ * A file that is missing, unreadable or not JSON contributes nothing. This is
19
+ * a best effort by design: the CLI still reads the same files, and a server
20
+ * this misses is a server that works exactly as it did before.
21
+ */
22
+ export function serversFor(directories: string[]): Record<string, Bag> {
23
+ const found: Record<string, Bag> = {};
24
+ const files = [
25
+ join(homedir(), '.claude.json'),
26
+ ...directories.map((dir) => join(dir, '.mcp.json')),
27
+ ];
28
+ for (const file of files) {
29
+ let held: unknown;
30
+ // Synchronous on purpose: these are two small files read once when a
31
+ // session is created, and a backend's `create` answers with a session
32
+ // rather than a promise of one.
33
+ try { held = JSON.parse(readFileSync(file, 'utf8')) as unknown; }
34
+ catch { continue; }
35
+ const bag = typeof held === 'object' && held !== null ? held as Bag : {};
36
+ const servers = typeof bag.mcpServers === 'object' && bag.mcpServers !== null ? bag.mcpServers as Bag : {};
37
+ for (const [name, config] of Object.entries(servers)) {
38
+ // Later wins: a project's own `.mcp.json` is nearer than the home file,
39
+ // which is the order the CLI resolves them in.
40
+ if (typeof config === 'object' && config !== null) found[name] = config as Bag;
41
+ }
42
+ }
43
+ return found;
44
+ }
45
+
46
+ /** The URL an http or sse server lives at, or nothing for a stdio one. */
47
+ export const urlOf = (config: unknown): string | undefined => {
48
+ const bag = typeof config === 'object' && config !== null ? config as Bag : {};
49
+ const url = typeof bag.url === 'string' ? bag.url : undefined;
50
+ if (url === undefined) return undefined;
51
+ const kind = typeof bag.type === 'string' ? bag.type : 'http';
52
+ return kind === 'http' || kind === 'sse' ? url : undefined;
53
+ };
54
+
55
+ /**
56
+ * What an MCP server says about signing into it, per RFC 9728.
57
+ *
58
+ * `<url>/.well-known/oauth-protected-resource`, which is where an OAuth
59
+ * protected resource publishes its metadata and where a client looks to find
60
+ * the authorization server. Answers the discovered document, or a bare one
61
+ * naming the server itself: the URL *is* the canonical resource identifier,
62
+ * so an incomplete answer is still a true one - what a client loses is the
63
+ * one-click sign-in, not the knowledge that it needs to sign in.
64
+ */
65
+ export async function protectedResource(url: string, name: string, ms = 15_000): Promise<Bag> {
66
+ const bare: Bag = { resource: url, resource_name: name };
67
+ const found = new URL(url);
68
+ const at = `${found.origin}/.well-known/oauth-protected-resource${found.pathname.replace(/\/+$/, '')}`;
69
+ const said = await fetch(at, { signal: AbortSignal.timeout(ms) })
70
+ .then((answer) => (answer.ok ? answer.json() as Promise<unknown> : undefined))
71
+ .catch(() => undefined);
72
+ if (typeof said !== 'object' || said === null) return bare;
73
+ const metadata = said as Bag;
74
+ return typeof metadata.resource === 'string' ? metadata : bare;
75
+ }
package/src/probe.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { customizationsOf, EFFORT_LABELS, EFFORTS } from './session.js';
3
+ import type { Bag, Offered } from '@ahpd/server';
4
+
5
+ /**
6
+ * Reads what the agent backend offers, once, without creating a session.
7
+ *
8
+ * Clients ask `resolveSessionConfig` before creating anything, so the models
9
+ * and commands have to be known before any session exists. This starts one
10
+ * short-lived agent process at startup, asks it over the control protocol,
11
+ * and closes it. No prompt is sent and no transcript is written.
12
+ */
13
+
14
+ const bag = (value: unknown): Bag => (typeof value === 'object' && value !== null ? value as Bag : {});
15
+ const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
16
+ const str = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
17
+
18
+ /**
19
+ * How hard *this* model can be told to think, as its own config schema.
20
+ *
21
+ * Per model and not per session, which is the whole point: the CLI reports a
22
+ * different `supportedEffortLevels` for each - some take all five, some take
23
+ * one, and some take none - so a single session-wide effort control offers
24
+ * levels the chosen model may not have, and accepts one it will then ignore.
25
+ * A model that supports none gets no schema and a client draws no control,
26
+ * which is the honest form of "this one does not think harder on request".
27
+ *
28
+ * `thinkingLevel` is the key, because that is the one the reference client's
29
+ * picker writes into `ModelSelection.config` for both of its providers.
30
+ */
31
+ const thinkingFor = (efforts: string[]): { configSchema?: Record<string, unknown> } => {
32
+ if (efforts.length === 0) return {};
33
+ return {
34
+ configSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ thinkingLevel: {
38
+ type: 'string',
39
+ title: 'Thinking Level',
40
+ description: 'Controls how much reasoning effort Claude uses.',
41
+ enum: [...efforts],
42
+ enumLabels: efforts.map((one) => EFFORT_LABELS[one as typeof EFFORTS[number]] ?? one),
43
+ ...(efforts.includes('high') ? { default: 'high' } : {}),
44
+ },
45
+ },
46
+ },
47
+ };
48
+ };
49
+
50
+ export async function probe(cwd: string): Promise<Offered> {
51
+ // A prompt that never yields. The query needs one to exist; it does not need
52
+ // one to answer what it can do.
53
+ async function* silence(): AsyncGenerator<never> {
54
+ await new Promise<void>(() => {});
55
+ // eslint-disable-next-line no-unreachable
56
+ return;
57
+ }
58
+
59
+ const handle = query({ prompt: silence(), options: { cwd } } as Parameters<typeof query>[0]);
60
+ try {
61
+ const [init, mcp, skills] = await Promise.all([
62
+ handle.initializationResult().then((answer) => bag(answer as unknown)),
63
+ // Best effort beside the one that matters: a harness with no MCP servers
64
+ // and one that will not say are the same empty list here, and neither is
65
+ // worth failing the probe over.
66
+ handle.mcpServerStatus().then((answer) => (Array.isArray(answer) ? answer : [])).catch(() => [] as unknown[]),
67
+ handle.reloadSkills().then((answer) => list(bag(answer as unknown).skills)).catch(() => [] as unknown[]),
68
+ ]);
69
+ const styles = list(init.available_output_styles).filter((s): s is string => typeof s === 'string');
70
+ return {
71
+ customizations: customizationsOf(init, mcp, skills),
72
+ // Only when the harness has them. An empty list would draw a picker
73
+ // with nothing in it, which is worse than no control.
74
+ ...(styles.length > 0 ? { outputStyles: styles } : {}),
75
+ ...(str(init.output_style) ? { outputStyle: str(init.output_style) as string } : {}),
76
+ models: list(init.models)
77
+ // `value`, not `id`.
78
+ .map((raw) => {
79
+ const model = bag(raw);
80
+ return {
81
+ id: str(model.value) ?? '',
82
+ name: str(model.displayName) ?? str(model.value) ?? '',
83
+ ...thinkingFor(list(model.supportedEffortLevels).filter((one): one is string => typeof one === 'string')),
84
+ };
85
+ })
86
+ .filter((model) => model.id !== ''),
87
+ commands: list(init.commands)
88
+ .map((raw) => {
89
+ const command = bag(raw);
90
+ return {
91
+ name: str(command.name) ?? '',
92
+ ...(str(command.description) ? { description: str(command.description) as string } : {}),
93
+ ...(str(command.argumentHint) ? { argumentHint: str(command.argumentHint) as string } : {}),
94
+ };
95
+ })
96
+ .filter((command) => command.name !== ''),
97
+ };
98
+ } catch {
99
+ // A harness that will not answer offers nothing, which is a real answer.
100
+ return { models: [], commands: [], customizations: [] };
101
+ } finally {
102
+ handle.close();
103
+ }
104
+ }