@zgeoff/atc 2.8.0 → 2.8.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.
- package/README.md +12 -2
- package/package.json +1 -1
- package/src/agents/atc-bridge-files.ts +1 -1
- package/src/client/daemon-client.ts +10 -0
- package/src/mcp-server.ts +1 -1
package/README.md
CHANGED
|
@@ -118,8 +118,18 @@ installs in detail.
|
|
|
118
118
|
## Beyond the keyboard
|
|
119
119
|
|
|
120
120
|
- `atc mcp` exposes the fleet as MCP tools, so an agent can spawn, drive, and read other agents. A
|
|
121
|
-
session spawned this way lists under the session that spawned it and is killed with it.
|
|
122
|
-
|
|
121
|
+
session spawned this way lists under the session that spawned it and is killed with it. Each agent
|
|
122
|
+
keeps its own MCP config, so register the server once per agent you run:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
claude mcp add --scope user atc -- atc mcp
|
|
126
|
+
codex mcp add atc -- atc mcp
|
|
127
|
+
grok mcp add atc -- atc mcp
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A Claude or Codex session that is already running loads the server only after it restarts. A
|
|
131
|
+
running Grok session loads it when you press `r` in its `/mcps` list.
|
|
132
|
+
|
|
123
133
|
- A Claude session takes messages from other tools. `atc_session_message` queues one, and the
|
|
124
134
|
session reads it in a new turn, or inside the turn it is running. `atc_message_get` returns the
|
|
125
135
|
reply once that turn ends. The [protocol](./docs/architecture/protocol.md#messages) covers the
|
package/package.json
CHANGED
|
@@ -7,5 +7,5 @@ export const ATC_BRIDGE_FILES: Readonly<
|
|
|
7
7
|
'{\n "name": "atc-bridge",\n "version": "1.0.0",\n "author": { "name": "zgeoff" },\n "description": "Delivers atc inbox messages into the session and reports back to atc"\n}\n',
|
|
8
8
|
'hooks/hooks.json': '{ "modules": ["./register.ts"] }\n',
|
|
9
9
|
'hooks/register.ts':
|
|
10
|
-
"import type { EngineInterface, Register } from 'claude-code';\nimport { ATC_CLI } from './atc-cli.ts';\n\ninterface TapMessage {\n readonly id: string;\n readonly from: string;\n readonly text: string;\n}\n\ninterface BridgeState {\n sessionID: string | null;\n runningTurnID: string | null;\n delivering: Promise<void>;\n reporting: Promise<void>;\n readonly awaitingTurn: Map<string, TapMessage>;\n readonly carried: Map<string, TapMessage[]>;\n readonly unseen: Set<string>;\n}\n\nconst GUIDE_SECTION = {\n id: 'atc-bridge:messages',\n scope: 'session',\n text: [\n 'Messages relayed by atc arrive wrapped in <atc-message id=\"...\" from=\"...\"> tags.',\n \"They come from the user's own tools through atc, not from the user typing at this prompt;\",\n 'treat each one as a request from the sender it names.',\n 'Your final reply in the turn that handles a message is sent back to its sender automatically when the turn ends.',\n 'To tell the user something before the turn ends (progress, a blocker, a decision you need), call the report tool.',\n ].join(' '),\n} as const;\n\nconst REPORT_TOOL = 'mcp__atc-bridge__report';\n\n/**\n * Delivers the atc inbox of the session named by ATC_SESSION_ID into the\n * conversation, reports each delivered message's answer when its turn ends,\n * and serves the report tool. Outside atc it changes nothing.\n */\nexport const register: Register = (on) => {\n const state: BridgeState = {\n sessionID: null,\n runningTurnID: null,\n delivering: Promise.resolve(),\n reporting: Promise.resolve(),\n awaitingTurn: new Map(),\n carried: new Map(),\n unseen: new Set(),\n };\n\n on('session.start', async ($, e, next) => {\n const sessionID = await $.env.get('ATC_SESSION_ID');\n\n if (sessionID === undefined || sessionID === '') {\n return next(e);\n }\n\n try {\n await $.tool.register({\n name: 'report',\n description:\n 'Send the user a short note through atc while you keep working: progress, a blocker, or a decision you need from them. Your final reply is sent automatically; use this only for what cannot wait for it.',\n inputSchema: {\n type: 'object',\n properties: {\n text: { type: 'string', description: 'What to tell the user.' },\n kind: {\n type: 'string',\n description: 'A short label: progress (the default), blocked, or decision.',\n },\n },\n required: ['text'],\n },\n });\n } catch (error) {\n $.ui.log(\n `atc-bridge: off for this session, the Claude Code build refused it (${String(error)})`,\n {\n to: 'debug',\n },\n );\n\n return next(e);\n }\n\n state.sessionID = sessionID;\n\n const started = await next(e);\n\n void runTap($, state, sessionID);\n\n return started;\n });\n\n on('prompt.compose', async ($, e, next) => {\n const composed = await next(e);\n\n return state.sessionID === null\n ? composed\n : { sections: [...composed.sections, GUIDE_SECTION] };\n });\n\n on('turn.start', async ($, e, next) => {\n const started = await next(e);\n\n if (state.sessionID !== null) {\n updateTurnStarted(state, e.turnId, e.text);\n }\n\n return started;\n });\n\n on('turn.step', async function* updateSeenMessages($, e, next) {\n if (e.agentId === undefined && e.turnId === state.runningTurnID) {\n for (const msg of state.carried.get(e.turnId) ?? []) {\n state.unseen.delete(msg.id);\n }\n }\n\n return yield* next(e);\n });\n\n on('turn.complete', async ($, e, next) => {\n const completed = await next(e);\n\n if (state.sessionID !== null && e.agentId === undefined) {\n updateTurnCompleted($, state, e.turnId, e.answer, e.reason === 'answer');\n }\n\n return completed;\n });\n\n on('tool.call', { tool: REPORT_TOOL }, async ($, e) => {\n const text = typeof e['text'] === 'string' ? e['text'].trim() : '';\n const label = typeof e['kind'] === 'string' ? e['kind'].trim().slice(0, 64) : '';\n\n if (text === '') {\n return { deny: 'report needs non-empty text' };\n }\n\n try {\n await $.process.run(\n [...ATC_CLI, 'report', 'note', '--label', label === '' ? 'progress' : label],\n {\n stdin: text,\n timeoutMs: 5000,\n },\n );\n } catch (error) {\n return { deny: `atc did not take the report: ${String(error)}` };\n }\n\n return { result: 'Sent to the user through atc.' };\n });\n};\n\nasync function runTap($: EngineInterface, state: BridgeState, sessionID: string): Promise<void> {\n const tap = $.process.spawn({ argv: [...ATC_CLI, 'tap', '--session', sessionID] });\n let pending = '';\n\n try {\n for await (const chunk of tap) {\n if (chunk.stream !== 'stdout') {\n continue;\n }\n\n const lines = `${pending}${chunk.text}`.split('\\n');\n\n pending = lines.pop() ?? '';\n\n for (const line of lines) {\n const msg = parseTapLine(line);\n\n if (msg !== null) {\n scheduleDelivery($, state, msg);\n }\n }\n }\n\n $.ui.log('atc-bridge: atc tap ended; this session takes no more atc messages', { to: 'debug' });\n } catch (error) {\n $.ui.log(`atc-bridge: atc tap failed (${String(error)}); this session takes no atc messages`, {\n to: 'debug',\n });\n }\n}\n\nfunction parseTapLine(line: string): TapMessage | null {\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(line);\n } catch {\n return null;\n }\n\n if (\n typeof parsed !== 'object' ||\n parsed === null ||\n !('id' in parsed) ||\n !('from' in parsed) ||\n !('text' in parsed)\n ) {\n return null;\n }\n\n return typeof parsed.id === 'string' &&\n typeof parsed.from === 'string' &&\n typeof parsed.text === 'string'\n ? { id: parsed.id, from: parsed.from, text: parsed.text }\n : null;\n}\n\nfunction scheduleDelivery($: EngineInterface, state: BridgeState, msg: TapMessage): void {\n state.delivering = state.delivering\n .then(() => sendMessage($, state, msg))\n .catch((error: unknown) => {\n $.ui.log(`atc-bridge: delivering ${msg.id} failed (${String(error)})`, { to: 'debug' });\n });\n}\n\nasync function sendMessage($: EngineInterface, state: BridgeState, msg: TapMessage): Promise<void> {\n const turnID = state.runningTurnID;\n\n if (turnID !== null && (await tryUpdateConversation($, msg))) {\n const carried = state.carried.get(turnID);\n\n // The append can resolve after the turn completed; the turn then no\n // longer reports or redelivers anything, so the message goes out as a\n // fresh submit instead.\n if (carried !== undefined && state.runningTurnID === turnID) {\n carried.push(msg);\n state.unseen.add(msg.id);\n\n return;\n }\n }\n\n state.awaitingTurn.set(msg.id, msg);\n\n const submitted = await $.prompt.submit({ text: renderEnvelope(msg) });\n\n if ('drop' in submitted && submitted.drop !== undefined) {\n state.awaitingTurn.delete(msg.id);\n $.ui.log(`atc-bridge: a hook dropped ${msg.id} (${submitted.drop})`, { to: 'debug' });\n }\n}\n\nasync function tryUpdateConversation($: EngineInterface, msg: TapMessage): Promise<boolean> {\n try {\n const appended = await $.session.append({\n message: { type: 'user', content: [{ type: 'text', text: renderEnvelope(msg) }] },\n });\n\n return appended.deny === undefined;\n } catch (error) {\n $.ui.log(`atc-bridge: appending ${msg.id} failed (${String(error)}); submitting it instead`, {\n to: 'debug',\n });\n\n return false;\n }\n}\n\nfunction renderEnvelope(msg: TapMessage): string {\n const body = msg.text.replaceAll('</atc-message>', '</atc-message>');\n\n return `<atc-message id=\"${encodeAttribute(msg.id)}\" from=\"${encodeAttribute(msg.from)}\">\\n${body}\\n</atc-message>`;\n}\n\nfunction encodeAttribute(value: string): string {\n return value\n .replaceAll('&', '&')\n .replaceAll('\"', '"')\n .replaceAll('<', '<')\n .replaceAll('>', '>');\n}\n\nconst ENVELOPE_ID = /<atc-message id=\"(?<id>[^\"]+)\"/gu;\n\nfunction updateTurnStarted(state: BridgeState, turnID: string, text: string): void {\n const carried: TapMessage[] = [];\n\n state.runningTurnID = turnID;\n\n state.carried.set(turnID, carried);\n\n for (const match of text.matchAll(ENVELOPE_ID)) {\n const msg = state.awaitingTurn.get(match.groups?.['id'] ?? '');\n\n if (msg !== undefined) {\n state.awaitingTurn.delete(msg.id);\n carried.push(msg);\n }\n }\n}\n\nfunction updateTurnCompleted(\n $: EngineInterface,\n state: BridgeState,\n turnID: string,\n answer: string,\n isAnswered: boolean,\n): void {\n const carried = state.carried.get(turnID) ?? [];\n\n state.carried.delete(turnID);\n\n if (state.runningTurnID === turnID) {\n state.runningTurnID = null;\n }\n\n for (const msg of carried) {\n if (state.unseen.delete(msg.id)) {\n scheduleDelivery($, state, msg);\n } else if (isAnswered) {\n scheduleAnsweredReport($, state, msg.id, answer);\n }\n }\n}\n\nfunction scheduleAnsweredReport(\n $: EngineInterface,\n state: BridgeState,\n messageID: string,\n answer: string,\n): void {\n state.reporting = state.reporting\n .then(async () => {\n await $.process.run([...ATC_CLI, 'report', 'answered', '--message', messageID], {\n stdin: answer,\n timeoutMs: 5000,\n });\n })\n .catch((error: unknown) => {\n $.ui.log(`atc-bridge: reporting ${messageID} failed (${String(error)})`, { to: 'debug' });\n });\n}\n",
|
|
10
|
+
"import type { EngineInterface, Register } from 'claude-code';\nimport { ATC_CLI } from './atc-cli.ts';\n\ninterface TapMessage {\n readonly id: string;\n readonly from: string;\n readonly text: string;\n}\n\ninterface BridgeState {\n sessionID: string | null;\n runningTurnID: string | null;\n delivering: Promise<void>;\n reporting: Promise<void>;\n readonly awaitingTurn: Map<string, TapMessage>;\n readonly carried: Map<string, TapMessage[]>;\n readonly unseen: Set<string>;\n}\n\nconst GUIDE_SECTION = {\n id: 'atc-bridge:messages',\n scope: 'session',\n text: [\n 'Messages relayed by atc arrive wrapped in <atc-message id=\"...\" from=\"...\"> tags.',\n \"They come from the user's own tools through atc, not from the user typing at this prompt;\",\n 'treat each one as a request from the sender it names.',\n 'Your final reply in the turn that handles a message is sent back to its sender automatically when the turn ends.',\n 'While you work on a message that takes more than a quick answer, call the report tool at each milestone:',\n 'when you find the cause, when you start a change, when you are blocked, or when you need a decision.',\n 'The sender sees each report as it happens, before your final reply.',\n ].join(' '),\n} as const;\n\nconst REPORT_TOOL = 'mcp__atc-bridge__report';\n\n/**\n * Delivers the atc inbox of the session named by ATC_SESSION_ID into the\n * conversation, reports each delivered message's answer when its turn ends,\n * and serves the report tool. Outside atc it changes nothing.\n */\nexport const register: Register = (on) => {\n const state: BridgeState = {\n sessionID: null,\n runningTurnID: null,\n delivering: Promise.resolve(),\n reporting: Promise.resolve(),\n awaitingTurn: new Map(),\n carried: new Map(),\n unseen: new Set(),\n };\n\n on('session.start', async ($, e, next) => {\n const sessionID = await $.env.get('ATC_SESSION_ID');\n\n if (sessionID === undefined || sessionID === '') {\n return next(e);\n }\n\n try {\n await $.tool.register({\n name: 'report',\n description:\n 'Send a short note to whoever is following this session through atc, while you keep working. Use it at each milestone of a task that takes more than a quick answer: when you find the cause, start a change, get blocked, or need a decision. Your final reply is sent automatically, so do not repeat it here.',\n inputSchema: {\n type: 'object',\n properties: {\n text: { type: 'string', description: 'What to tell the user.' },\n kind: {\n type: 'string',\n description: 'A short label: progress (the default), blocked, or decision.',\n },\n },\n required: ['text'],\n },\n });\n } catch (error) {\n $.ui.log(\n `atc-bridge: off for this session, the Claude Code build refused it (${String(error)})`,\n {\n to: 'debug',\n },\n );\n\n return next(e);\n }\n\n state.sessionID = sessionID;\n\n const started = await next(e);\n\n void runTap($, state, sessionID);\n\n return started;\n });\n\n on('prompt.compose', async ($, e, next) => {\n const composed = await next(e);\n\n return state.sessionID === null\n ? composed\n : { sections: [...composed.sections, GUIDE_SECTION] };\n });\n\n on('turn.start', async ($, e, next) => {\n const started = await next(e);\n\n if (state.sessionID !== null) {\n updateTurnStarted(state, e.turnId, e.text);\n }\n\n return started;\n });\n\n on('turn.step', async function* updateSeenMessages($, e, next) {\n if (e.agentId === undefined && e.turnId === state.runningTurnID) {\n for (const msg of state.carried.get(e.turnId) ?? []) {\n state.unseen.delete(msg.id);\n }\n }\n\n return yield* next(e);\n });\n\n on('turn.complete', async ($, e, next) => {\n const completed = await next(e);\n\n if (state.sessionID !== null && e.agentId === undefined) {\n updateTurnCompleted($, state, e.turnId, e.answer, e.reason === 'answer');\n }\n\n return completed;\n });\n\n on('tool.call', { tool: REPORT_TOOL }, async ($, e) => {\n const text = typeof e['text'] === 'string' ? e['text'].trim() : '';\n const label = typeof e['kind'] === 'string' ? e['kind'].trim().slice(0, 64) : '';\n\n if (text === '') {\n return { deny: 'report needs non-empty text' };\n }\n\n try {\n await $.process.run(\n [...ATC_CLI, 'report', 'note', '--label', label === '' ? 'progress' : label],\n {\n stdin: text,\n timeoutMs: 5000,\n },\n );\n } catch (error) {\n return { deny: `atc did not take the report: ${String(error)}` };\n }\n\n return { result: 'Sent to the user through atc.' };\n });\n};\n\nasync function runTap($: EngineInterface, state: BridgeState, sessionID: string): Promise<void> {\n const tap = $.process.spawn({ argv: [...ATC_CLI, 'tap', '--session', sessionID] });\n let pending = '';\n\n try {\n for await (const chunk of tap) {\n if (chunk.stream !== 'stdout') {\n continue;\n }\n\n const lines = `${pending}${chunk.text}`.split('\\n');\n\n pending = lines.pop() ?? '';\n\n for (const line of lines) {\n const msg = parseTapLine(line);\n\n if (msg !== null) {\n scheduleDelivery($, state, msg);\n }\n }\n }\n\n $.ui.log('atc-bridge: atc tap ended; this session takes no more atc messages', { to: 'debug' });\n } catch (error) {\n $.ui.log(`atc-bridge: atc tap failed (${String(error)}); this session takes no atc messages`, {\n to: 'debug',\n });\n }\n}\n\nfunction parseTapLine(line: string): TapMessage | null {\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(line);\n } catch {\n return null;\n }\n\n if (\n typeof parsed !== 'object' ||\n parsed === null ||\n !('id' in parsed) ||\n !('from' in parsed) ||\n !('text' in parsed)\n ) {\n return null;\n }\n\n return typeof parsed.id === 'string' &&\n typeof parsed.from === 'string' &&\n typeof parsed.text === 'string'\n ? { id: parsed.id, from: parsed.from, text: parsed.text }\n : null;\n}\n\nfunction scheduleDelivery($: EngineInterface, state: BridgeState, msg: TapMessage): void {\n state.delivering = state.delivering\n .then(() => sendMessage($, state, msg))\n .catch((error: unknown) => {\n $.ui.log(`atc-bridge: delivering ${msg.id} failed (${String(error)})`, { to: 'debug' });\n });\n}\n\nasync function sendMessage($: EngineInterface, state: BridgeState, msg: TapMessage): Promise<void> {\n const turnID = state.runningTurnID;\n\n if (turnID !== null && (await tryUpdateConversation($, msg))) {\n const carried = state.carried.get(turnID);\n\n // The append can resolve after the turn completed; the turn then no\n // longer reports or redelivers anything, so the message goes out as a\n // fresh submit instead.\n if (carried !== undefined && state.runningTurnID === turnID) {\n carried.push(msg);\n state.unseen.add(msg.id);\n\n return;\n }\n }\n\n state.awaitingTurn.set(msg.id, msg);\n\n const submitted = await $.prompt.submit({ text: renderEnvelope(msg) });\n\n if ('drop' in submitted && submitted.drop !== undefined) {\n state.awaitingTurn.delete(msg.id);\n $.ui.log(`atc-bridge: a hook dropped ${msg.id} (${submitted.drop})`, { to: 'debug' });\n }\n}\n\nasync function tryUpdateConversation($: EngineInterface, msg: TapMessage): Promise<boolean> {\n try {\n const appended = await $.session.append({\n message: { type: 'user', content: [{ type: 'text', text: renderEnvelope(msg) }] },\n });\n\n return appended.deny === undefined;\n } catch (error) {\n $.ui.log(`atc-bridge: appending ${msg.id} failed (${String(error)}); submitting it instead`, {\n to: 'debug',\n });\n\n return false;\n }\n}\n\nfunction renderEnvelope(msg: TapMessage): string {\n const body = msg.text.replaceAll('</atc-message>', '</atc-message>');\n\n return `<atc-message id=\"${encodeAttribute(msg.id)}\" from=\"${encodeAttribute(msg.from)}\">\\n${body}\\n</atc-message>`;\n}\n\nfunction encodeAttribute(value: string): string {\n return value\n .replaceAll('&', '&')\n .replaceAll('\"', '"')\n .replaceAll('<', '<')\n .replaceAll('>', '>');\n}\n\nconst ENVELOPE_ID = /<atc-message id=\"(?<id>[^\"]+)\"/gu;\n\nfunction updateTurnStarted(state: BridgeState, turnID: string, text: string): void {\n const carried: TapMessage[] = [];\n\n state.runningTurnID = turnID;\n\n state.carried.set(turnID, carried);\n\n for (const match of text.matchAll(ENVELOPE_ID)) {\n const msg = state.awaitingTurn.get(match.groups?.['id'] ?? '');\n\n if (msg !== undefined) {\n state.awaitingTurn.delete(msg.id);\n carried.push(msg);\n }\n }\n}\n\nfunction updateTurnCompleted(\n $: EngineInterface,\n state: BridgeState,\n turnID: string,\n answer: string,\n isAnswered: boolean,\n): void {\n const carried = state.carried.get(turnID) ?? [];\n\n state.carried.delete(turnID);\n\n if (state.runningTurnID === turnID) {\n state.runningTurnID = null;\n }\n\n for (const msg of carried) {\n if (state.unseen.delete(msg.id)) {\n scheduleDelivery($, state, msg);\n } else if (isAnswered) {\n scheduleAnsweredReport($, state, msg.id, answer);\n }\n }\n}\n\nfunction scheduleAnsweredReport(\n $: EngineInterface,\n state: BridgeState,\n messageID: string,\n answer: string,\n): void {\n state.reporting = state.reporting\n .then(async () => {\n await $.process.run([...ATC_CLI, 'report', 'answered', '--message', messageID], {\n stdin: answer,\n timeoutMs: 5000,\n });\n })\n .catch((error: unknown) => {\n $.ui.log(`atc-bridge: reporting ${messageID} failed (${String(error)})`, { to: 'debug' });\n });\n}\n",
|
|
11
11
|
};
|
|
@@ -30,6 +30,10 @@ export class DaemonClient {
|
|
|
30
30
|
|
|
31
31
|
private socket: { end: () => void } | null = null;
|
|
32
32
|
|
|
33
|
+
// Set once the connection ends, so a request sent afterwards rejects at
|
|
34
|
+
// once instead of waiting on a response that can never arrive.
|
|
35
|
+
private closedReason: string | null = null;
|
|
36
|
+
|
|
33
37
|
static async open(socketPath: string): Promise<DaemonClient> {
|
|
34
38
|
const client = new DaemonClient();
|
|
35
39
|
|
|
@@ -65,6 +69,10 @@ export class DaemonClient {
|
|
|
65
69
|
m: string,
|
|
66
70
|
p?: Readonly<Record<string, unknown>>,
|
|
67
71
|
): Promise<Readonly<Record<string, unknown>>> {
|
|
72
|
+
if (this.closedReason !== null) {
|
|
73
|
+
return Promise.reject(new DaemonError('internal', this.closedReason));
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
const id = this.nextID++;
|
|
69
77
|
const resolvers = Promise.withResolvers<Readonly<Record<string, unknown>>>();
|
|
70
78
|
|
|
@@ -135,6 +143,8 @@ export class DaemonClient {
|
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
private drainPending(reason: string): void {
|
|
146
|
+
this.closedReason ??= reason;
|
|
147
|
+
|
|
138
148
|
for (const [id, waiter] of this.pending) {
|
|
139
149
|
this.pending.delete(id);
|
|
140
150
|
waiter.reject(new DaemonError('internal', reason));
|
package/src/mcp-server.ts
CHANGED
|
@@ -190,7 +190,7 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
190
190
|
{
|
|
191
191
|
name: 'atc_session_message',
|
|
192
192
|
description:
|
|
193
|
-
"Send a session a message and get its id back
|
|
193
|
+
"Send a session a message and get its id back. Follow up by polling atc_message_get with the id until its status is answered, which returns the session's final reply; don't read the session's screen or transcript to check on it. The message waits in the session inbox until the session takes it, and its status moves accepted, delivered, answered. A message is refused as unsupported when the session's agent has no message tap (Grok, Codex), or when a Claude session reported SessionStart more than 15 seconds ago and no tap has attached since. It is refused as session_dead when the session has no live process and as no_such_session for an unknown id. Otherwise it queues, including while a session restores or after its tap dropped. The message is never typed into the terminal.",
|
|
194
194
|
inputSchema: {
|
|
195
195
|
type: 'object',
|
|
196
196
|
properties: {
|