@zgeoff/atc 2.10.3 → 2.11.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/README.md +3 -3
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +21 -0
- package/src/agents/atc-bridge-files.ts +1 -1
- package/src/agents/claude-adapter.ts +4 -0
- package/src/agents/codex-adapter.ts +4 -0
- package/src/agents/gateway-adapter.ts +31 -0
- package/src/agents/grok-adapter.ts +4 -0
- package/src/cli.ts +4 -0
- package/src/client/boot-daemon.ts +6 -0
- package/src/daemon/build-agent-list.ts +58 -0
- package/src/daemon/daemon-connection.ts +44 -8
- package/src/daemon/daemon.ts +92 -22
- package/src/daemon/parse-report.ts +29 -3
- package/src/daemon/sessions.ts +5 -0
- package/src/mcp/answer-rpc-request.ts +13 -6
- package/src/mcp/build-tool-list.ts +60 -7
- package/src/mcp/mcp-tools.ts +151 -20
- package/src/mcp/reconnecting-caller.ts +42 -12
- package/src/mcp/require-daemon-features.ts +33 -0
- package/src/mcp/run-tool.ts +75 -23
- package/src/mcp/start-mcp-http-server.ts +130 -24
- package/src/mcp/types.ts +7 -1
- package/src/mcp-http-server.ts +6 -1
- package/src/mcp-server.ts +13 -1
- package/src/protocol/daemon-features.ts +23 -0
- package/src/protocol/parse-daemon-features.ts +19 -0
- package/src/protocol/request-param-schemas.ts +18 -2
- package/src/report.ts +20 -3
- package/src/store/message-record.ts +3 -0
- package/src/store/run-migrations.ts +18 -2
- package/src/store/state-store.ts +77 -10
|
@@ -4,8 +4,14 @@ import { toMessageID } from '../shared/to-message-id';
|
|
|
4
4
|
|
|
5
5
|
interface AnsweredReport {
|
|
6
6
|
readonly kind: 'answered';
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
// Every message the turn answered, recorded together.
|
|
9
|
+
readonly messages: readonly MessageID[];
|
|
8
10
|
readonly answer: string;
|
|
11
|
+
|
|
12
|
+
// The turn whose final reply the answer is; null from a reporter that
|
|
13
|
+
// sends none.
|
|
14
|
+
readonly turn: string | null;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
export interface NoteReport {
|
|
@@ -16,8 +22,27 @@ export interface NoteReport {
|
|
|
16
22
|
|
|
17
23
|
export type Report = AnsweredReport | NoteReport;
|
|
18
24
|
|
|
25
|
+
// Optional, so a report from an older bridge still parses; a missing, empty,
|
|
26
|
+
// or wrong-typed turn reads as unknown.
|
|
27
|
+
const TURN_SCHEMA = z.preprocess(
|
|
28
|
+
(v) => (typeof v === 'string' && v !== '' ? v : undefined),
|
|
29
|
+
z.string().optional(),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const MESSAGE_IDS_SCHEMA = z.array(z.string().min(1)).min(1).optional();
|
|
33
|
+
|
|
19
34
|
const REPORT_SCHEMA = z.discriminatedUnion('kind', [
|
|
20
|
-
z
|
|
35
|
+
z
|
|
36
|
+
.object({
|
|
37
|
+
kind: z.literal('answered'),
|
|
38
|
+
|
|
39
|
+
// One message, or every message one turn answered.
|
|
40
|
+
message: z.string().min(1).optional(),
|
|
41
|
+
messages: MESSAGE_IDS_SCHEMA,
|
|
42
|
+
answer: z.string(),
|
|
43
|
+
turn: TURN_SCHEMA,
|
|
44
|
+
})
|
|
45
|
+
.refine((v) => (v.message === undefined) !== (v.messages === undefined)),
|
|
21
46
|
z.object({ kind: z.literal('note'), label: z.string().min(1).max(64), text: z.string().min(1) }),
|
|
22
47
|
]);
|
|
23
48
|
|
|
@@ -38,7 +63,8 @@ export function parseReport(payload: Readonly<Record<string, unknown>>): Report
|
|
|
38
63
|
|
|
39
64
|
return {
|
|
40
65
|
kind: 'answered',
|
|
41
|
-
|
|
66
|
+
messages: (parsed.data.messages ?? [parsed.data.message ?? '']).map((id) => toMessageID(id)),
|
|
42
67
|
answer: parsed.data.answer,
|
|
68
|
+
turn: parsed.data.turn ?? null,
|
|
43
69
|
};
|
|
44
70
|
}
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -134,6 +134,11 @@ export class SessionManager {
|
|
|
134
134
|
return this.adapters[id] ?? null;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// Every registered adapter, one per id, in registration order.
|
|
138
|
+
collectAdapters(): AgentAdapter[] {
|
|
139
|
+
return Object.values(this.adapters);
|
|
140
|
+
}
|
|
141
|
+
|
|
137
142
|
// Hands a live terminal session off to a headless run: the terminal dies,
|
|
138
143
|
// the record lives on as a headless session and keeps its screen history.
|
|
139
144
|
yankHeadless(id: SessionID): Session | null {
|
|
@@ -57,10 +57,14 @@ export async function answerRPCRequest(message: unknown, deps: RPCDeps): Promise
|
|
|
57
57
|
}),
|
|
58
58
|
}))
|
|
59
59
|
.with('ping', () => ({ kind: 'reply' as const, body: buildRPCResult(id, {}) }))
|
|
60
|
-
.with('tools/list', () =>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
.with('tools/list', async () => {
|
|
61
|
+
const features = await deps.caller.readFeatures();
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
kind: 'reply' as const,
|
|
65
|
+
body: buildRPCResult(id, { tools: buildToolList(features) }),
|
|
66
|
+
};
|
|
67
|
+
})
|
|
64
68
|
.with('tools/call', async () => {
|
|
65
69
|
const result = await answerToolCall(deps, params);
|
|
66
70
|
|
|
@@ -101,9 +105,12 @@ async function answerToolCall(
|
|
|
101
105
|
const args = isRecord(params['arguments']) ? params['arguments'] : {};
|
|
102
106
|
|
|
103
107
|
try {
|
|
104
|
-
const
|
|
108
|
+
const result = await runTool(deps.caller, name, args, deps.toolContext);
|
|
105
109
|
|
|
106
|
-
return {
|
|
110
|
+
return {
|
|
111
|
+
content: [{ type: 'text', text: result.text }],
|
|
112
|
+
...(result.structured === null ? {} : { structuredContent: result.structured }),
|
|
113
|
+
};
|
|
107
114
|
} catch (error) {
|
|
108
115
|
return { content: [{ type: 'text', text: formatToolError(error) }], isError: true };
|
|
109
116
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
2
|
+
import { isRecord } from '../shared/report';
|
|
1
3
|
import { MCP_TOOLS } from './mcp-tools';
|
|
2
4
|
|
|
3
5
|
interface MCPTool {
|
|
4
6
|
readonly name: string;
|
|
5
7
|
readonly description: string;
|
|
6
8
|
readonly inputSchema: Readonly<Record<string, unknown>>;
|
|
9
|
+
readonly outputSchema?: Readonly<Record<string, unknown>>;
|
|
7
10
|
readonly annotations: {
|
|
8
11
|
readonly readOnlyHint: boolean;
|
|
9
12
|
readonly destructiveHint: boolean;
|
|
@@ -11,11 +14,61 @@ interface MCPTool {
|
|
|
11
14
|
};
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The tools `tools/list` returns for a daemon announcing the given
|
|
19
|
+
* features. A tool the daemon cannot serve is left out, and a tool it serves
|
|
20
|
+
* in an older form is listed without the output schema and input properties
|
|
21
|
+
* that form lacks, so a client never sees an option the daemon would ignore.
|
|
22
|
+
*/
|
|
23
|
+
export function buildToolList(features: ReadonlySet<DaemonFeature>): readonly MCPTool[] {
|
|
24
|
+
return MCP_TOOLS.flatMap((tool) => {
|
|
25
|
+
const requires = tool.requires ?? {};
|
|
26
|
+
|
|
27
|
+
if (requires.tool !== undefined && !features.has(requires.tool)) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const inputSchema = buildInputSchema(tool.inputSchema, requires.inputs ?? {}, features);
|
|
32
|
+
|
|
33
|
+
return [
|
|
34
|
+
tool.outputSchema === undefined ||
|
|
35
|
+
(requires.output !== undefined && !features.has(requires.output))
|
|
36
|
+
? {
|
|
37
|
+
name: tool.name,
|
|
38
|
+
description: tool.description,
|
|
39
|
+
inputSchema,
|
|
40
|
+
annotations: tool.annotations,
|
|
41
|
+
}
|
|
42
|
+
: {
|
|
43
|
+
name: tool.name,
|
|
44
|
+
description: tool.description,
|
|
45
|
+
inputSchema,
|
|
46
|
+
outputSchema: tool.outputSchema,
|
|
47
|
+
annotations: tool.annotations,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildInputSchema(
|
|
54
|
+
schema: Readonly<Record<string, unknown>>,
|
|
55
|
+
inputs: Readonly<Record<string, DaemonFeature>>,
|
|
56
|
+
features: ReadonlySet<DaemonFeature>,
|
|
57
|
+
): Readonly<Record<string, unknown>> {
|
|
58
|
+
const withheld = Object.entries(inputs).flatMap(([name, feature]) =>
|
|
59
|
+
features.has(feature) ? [] : [name],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const properties = schema['properties'];
|
|
63
|
+
|
|
64
|
+
if (withheld.length === 0 || !isRecord(properties)) {
|
|
65
|
+
return schema;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
...schema,
|
|
70
|
+
properties: Object.fromEntries(
|
|
71
|
+
Object.entries(properties).filter(([name]) => !withheld.includes(name)),
|
|
72
|
+
),
|
|
73
|
+
};
|
|
21
74
|
}
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
2
3
|
import { REQUEST_PARAM_SCHEMAS } from '../protocol/request-param-schemas';
|
|
3
4
|
import type { GrantScope } from '../shared/grant-scope';
|
|
4
5
|
|
|
@@ -54,8 +55,16 @@ const SESSION_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
54
55
|
{ io: 'input' },
|
|
55
56
|
);
|
|
56
57
|
|
|
58
|
+
const WAIT_MS = z.number().int().min(0).max(30_000).optional();
|
|
59
|
+
|
|
57
60
|
const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
58
61
|
z.strictObject({
|
|
62
|
+
session: z
|
|
63
|
+
.string()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe(
|
|
66
|
+
"An atc session id; limits the read to that session's events. Cursors stay valid across filtered and unfiltered reads",
|
|
67
|
+
),
|
|
59
68
|
cursor: z
|
|
60
69
|
.string()
|
|
61
70
|
.optional()
|
|
@@ -69,19 +78,121 @@ const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
69
78
|
.max(200)
|
|
70
79
|
.optional()
|
|
71
80
|
.describe('Most events to return; defaults to 50'),
|
|
72
|
-
waitMs:
|
|
73
|
-
.
|
|
74
|
-
|
|
75
|
-
.min(0)
|
|
76
|
-
.max(30_000)
|
|
77
|
-
.optional()
|
|
78
|
-
.describe(
|
|
79
|
-
'How long to wait for a new event when none is pending, in milliseconds; defaults to 0, capped at 30000. Keep it short.',
|
|
80
|
-
),
|
|
81
|
+
waitMs: WAIT_MS.describe(
|
|
82
|
+
'How long to wait for a new event when none is pending, in milliseconds; defaults to 0, capped at 30000. Keep it short.',
|
|
83
|
+
),
|
|
81
84
|
}),
|
|
82
85
|
{ io: 'input' },
|
|
83
86
|
);
|
|
84
87
|
|
|
88
|
+
const MESSAGE_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
89
|
+
z.strictObject({
|
|
90
|
+
message: z.string().describe('The message id atc_session_message returned'),
|
|
91
|
+
waitMs: WAIT_MS.describe(
|
|
92
|
+
'How long to hold the call until the message status changes from what it was when you called, in milliseconds; defaults to 0, capped at 30000',
|
|
93
|
+
),
|
|
94
|
+
}),
|
|
95
|
+
{ io: 'input' },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Output schemas leave further properties open, so a field the daemon adds
|
|
99
|
+
// later never fails a client that validates results against them.
|
|
100
|
+
const MESSAGE_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
message: { type: 'string' },
|
|
104
|
+
session: { type: 'string' },
|
|
105
|
+
from: { type: 'string' },
|
|
106
|
+
text: { type: 'string' },
|
|
107
|
+
status: { type: 'string', enum: ['accepted', 'delivered', 'answered'] },
|
|
108
|
+
answer: { type: 'string' },
|
|
109
|
+
turn: { type: ['string', 'null'] },
|
|
110
|
+
answeredWith: { type: 'array', items: { type: 'string' } },
|
|
111
|
+
sentAt: { type: 'number' },
|
|
112
|
+
deliveredAt: { type: 'number' },
|
|
113
|
+
answeredAt: { type: 'number' },
|
|
114
|
+
},
|
|
115
|
+
required: ['message', 'session', 'from', 'text', 'status', 'turn', 'answeredWith', 'sentAt'],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const MESSAGE_SENT_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
message: { type: 'string' },
|
|
122
|
+
status: { type: 'string', enum: ['accepted', 'delivered', 'answered'] },
|
|
123
|
+
},
|
|
124
|
+
required: ['message', 'status'],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const AGENTS_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {
|
|
130
|
+
daemon: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
hostname: { type: 'string' },
|
|
134
|
+
platform: { type: 'string' },
|
|
135
|
+
arch: { type: 'string' },
|
|
136
|
+
build: { type: 'string' },
|
|
137
|
+
},
|
|
138
|
+
required: ['hostname', 'platform', 'arch', 'build'],
|
|
139
|
+
},
|
|
140
|
+
agents: {
|
|
141
|
+
type: 'array',
|
|
142
|
+
items: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
id: { type: 'string' },
|
|
146
|
+
label: { type: 'string' },
|
|
147
|
+
kind: { type: 'string' },
|
|
148
|
+
installed: { type: 'boolean' },
|
|
149
|
+
capabilities: {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
spawn: { type: 'boolean' },
|
|
153
|
+
readTranscript: { type: 'boolean' },
|
|
154
|
+
message: { type: 'boolean' },
|
|
155
|
+
attach: { type: 'boolean' },
|
|
156
|
+
screen: { type: 'boolean' },
|
|
157
|
+
input: { type: 'boolean' },
|
|
158
|
+
},
|
|
159
|
+
required: ['spawn', 'readTranscript', 'message', 'attach', 'screen', 'input'],
|
|
160
|
+
},
|
|
161
|
+
models: { type: ['object', 'null'], additionalProperties: { type: 'string' } },
|
|
162
|
+
},
|
|
163
|
+
required: ['id', 'label', 'kind', 'installed', 'capabilities', 'models'],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
required: ['daemon', 'agents'],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const EVENTS_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
171
|
+
type: 'object',
|
|
172
|
+
properties: {
|
|
173
|
+
events: {
|
|
174
|
+
type: 'array',
|
|
175
|
+
items: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
cursor: { type: 'string' },
|
|
179
|
+
at: { type: 'number' },
|
|
180
|
+
session: { type: 'string' },
|
|
181
|
+
name: { type: ['string', 'null'] },
|
|
182
|
+
kind: { type: 'string' },
|
|
183
|
+
detail: { type: ['string', 'null'] },
|
|
184
|
+
message: { type: 'string' },
|
|
185
|
+
label: { type: 'string' },
|
|
186
|
+
},
|
|
187
|
+
required: ['cursor', 'at', 'session', 'name', 'kind', 'detail'],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
cursor: { type: 'string' },
|
|
191
|
+
more: { type: 'boolean' },
|
|
192
|
+
},
|
|
193
|
+
required: ['events', 'cursor', 'more'],
|
|
194
|
+
};
|
|
195
|
+
|
|
85
196
|
interface MCPToolAnnotations {
|
|
86
197
|
readonly readOnlyHint: boolean;
|
|
87
198
|
readonly destructiveHint: boolean;
|
|
@@ -92,7 +203,19 @@ interface MCPToolDefinition {
|
|
|
92
203
|
readonly name: string;
|
|
93
204
|
readonly description: string;
|
|
94
205
|
readonly inputSchema: Readonly<Record<string, unknown>>;
|
|
206
|
+
|
|
207
|
+
// The shape of the tool's structured result, for the tools that declare one.
|
|
208
|
+
readonly outputSchema?: Readonly<Record<string, unknown>>;
|
|
95
209
|
readonly annotations: MCPToolAnnotations;
|
|
210
|
+
|
|
211
|
+
// What the connected daemon has to announce for the tool to be listed at
|
|
212
|
+
// all, for its output schema to be declared, and for each listed input
|
|
213
|
+
// property to be offered. An older daemon gets the tool without them.
|
|
214
|
+
readonly requires?: {
|
|
215
|
+
readonly tool?: DaemonFeature;
|
|
216
|
+
readonly output?: DaemonFeature;
|
|
217
|
+
readonly inputs?: Readonly<Record<string, DaemonFeature>>;
|
|
218
|
+
};
|
|
96
219
|
readonly scope: GrantScope;
|
|
97
220
|
}
|
|
98
221
|
|
|
@@ -215,6 +338,16 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
215
338
|
description: 'List directories sessions were previously spawned from, most recent first.',
|
|
216
339
|
inputSchema: NO_INPUT,
|
|
217
340
|
},
|
|
341
|
+
{
|
|
342
|
+
name: 'atc_agents_list',
|
|
343
|
+
annotations: READ_ONLY,
|
|
344
|
+
scope: 'read',
|
|
345
|
+
description:
|
|
346
|
+
"List the agents this atc host can run sessions under, plus the host itself (daemon: hostname, platform, arch, build). Each agent has its id (pass it as atc_session_spawn's agent), label, kind (the agent CLI family it runs), installed (whether its binary resolves on this host; a registered agent that is not installed cannot spawn), capabilities (spawn, readTranscript, message, attach, screen, input), and models: the model names the config sets for it, or null. It never includes credentials, environment values, or endpoints, and holds nothing about which plans or subscriptions an agent's account has.",
|
|
347
|
+
inputSchema: NO_INPUT,
|
|
348
|
+
outputSchema: AGENTS_OUTPUT,
|
|
349
|
+
requires: { tool: 'agents.list' },
|
|
350
|
+
},
|
|
218
351
|
{
|
|
219
352
|
name: 'atc_session_get',
|
|
220
353
|
annotations: READ_ONLY,
|
|
@@ -236,15 +369,17 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
236
369
|
annotations: READ_ONLY,
|
|
237
370
|
scope: 'read',
|
|
238
371
|
description:
|
|
239
|
-
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label. Without a cursor it returns the most recent events. Pass the returned cursor next time. waitMs holds the call open until an event arrives.',
|
|
372
|
+
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label. Without a cursor it returns the most recent events. Pass the returned cursor next time; more is true when the page stopped before the newest event, so read again at once. session limits the read to one session. waitMs holds the call open until an event arrives; pass it instead of polling in a tight loop.',
|
|
240
373
|
inputSchema: EVENTS_READ_INPUT,
|
|
374
|
+
outputSchema: EVENTS_OUTPUT,
|
|
375
|
+
requires: { output: 'events.more', inputs: { session: 'events.session' } },
|
|
241
376
|
},
|
|
242
377
|
{
|
|
243
378
|
name: 'atc_session_message',
|
|
244
379
|
annotations: AGENT_FACING,
|
|
245
380
|
scope: 'message',
|
|
246
381
|
description:
|
|
247
|
-
"Send a session a message and get its id back. Follow up
|
|
382
|
+
"Send a session a message and get its id back. Follow up with atc_message_get, passing waitMs so each call waits for the next status change instead of polling in a tight loop, until its status is answered; don't read the session's screen or transcript to check on it. The answer is the final output of the session turn that carried the message, and one turn can carry several messages. 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.",
|
|
248
383
|
inputSchema: {
|
|
249
384
|
type: 'object',
|
|
250
385
|
properties: {
|
|
@@ -259,20 +394,16 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
259
394
|
required: ['session', 'text'],
|
|
260
395
|
additionalProperties: false,
|
|
261
396
|
},
|
|
397
|
+
outputSchema: MESSAGE_SENT_OUTPUT,
|
|
262
398
|
},
|
|
263
399
|
{
|
|
264
400
|
name: 'atc_message_get',
|
|
265
401
|
annotations: READ_ONLY,
|
|
266
402
|
scope: 'read',
|
|
267
403
|
description:
|
|
268
|
-
'Read one message sent with atc_session_message: its id, session, from, text, status (accepted, delivered, or answered), the answer once answered, and the sentAt, deliveredAt, and answeredAt timestamps.
|
|
269
|
-
inputSchema:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
message: { type: 'string', description: 'The message id atc_session_message returned' },
|
|
273
|
-
},
|
|
274
|
-
required: ['message'],
|
|
275
|
-
additionalProperties: false,
|
|
276
|
-
},
|
|
404
|
+
'Read one message sent with atc_session_message: its id, session, from, text, status (accepted, delivered, or answered), the answer once answered, turn, answeredWith, and the sentAt, deliveredAt, and answeredAt timestamps. The answer is the final output of the session turn that carried the message, not a reply to that message alone: when one turn carries several messages, each gets the same answer. turn is that turn id, or null when the session reported none, and answeredWith lists the other messages the same turn answered. Pass waitMs to hold the call until the status changes from what it was when you called, up to 30000 ms, instead of polling in a tight loop; an answered message returns at once. Message ids and statuses persist, so after a call ends or times out, call again with the same id.',
|
|
405
|
+
inputSchema: MESSAGE_GET_INPUT,
|
|
406
|
+
outputSchema: MESSAGE_OUTPUT,
|
|
407
|
+
requires: { output: 'message.turn', inputs: { waitMs: 'message.wait' } },
|
|
277
408
|
},
|
|
278
409
|
];
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { DaemonClient } from '../client/daemon-client';
|
|
2
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
3
|
+
import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
|
|
4
|
+
import { requireDaemonFeatures } from './require-daemon-features';
|
|
2
5
|
import type { FleetCaller } from './types';
|
|
3
6
|
|
|
4
7
|
// The daemon requests the read-only tools send. Each only reads, so running
|
|
5
8
|
// one twice is harmless.
|
|
6
9
|
const RETRYABLE_METHODS: ReadonlySet<string> = new Set([
|
|
10
|
+
'agents.list',
|
|
7
11
|
'dirs.list',
|
|
8
12
|
'events.read',
|
|
9
13
|
'message.get',
|
|
@@ -14,6 +18,12 @@ const RETRYABLE_METHODS: ReadonlySet<string> = new Set([
|
|
|
14
18
|
'session.screen',
|
|
15
19
|
]);
|
|
16
20
|
|
|
21
|
+
// One handshaken connection and the features its daemon announced.
|
|
22
|
+
interface DaemonConnection {
|
|
23
|
+
readonly client: DaemonClient;
|
|
24
|
+
readonly features: ReadonlySet<DaemonFeature>;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
/**
|
|
18
28
|
* A daemon caller that survives a daemon restart: once the connection ends,
|
|
19
29
|
* the next request opens and handshakes a fresh one. A request that was in
|
|
@@ -26,7 +36,7 @@ export class ReconnectingCaller implements FleetCaller {
|
|
|
26
36
|
|
|
27
37
|
private readonly build: string;
|
|
28
38
|
|
|
29
|
-
private client: Promise<
|
|
39
|
+
private client: Promise<DaemonConnection> | null = null;
|
|
30
40
|
|
|
31
41
|
private readonly closed = new WeakSet<DaemonClient>();
|
|
32
42
|
|
|
@@ -35,25 +45,41 @@ export class ReconnectingCaller implements FleetCaller {
|
|
|
35
45
|
this.build = build;
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
// The required features are checked against each connection right before
|
|
49
|
+
// the request goes out on it, the retry's fresh connection included, since
|
|
50
|
+
// a restart can put an older daemon behind the same socket.
|
|
38
51
|
async sendRequest(
|
|
39
52
|
m: string,
|
|
40
53
|
p?: Readonly<Record<string, unknown>>,
|
|
54
|
+
required: readonly DaemonFeature[] = [],
|
|
41
55
|
): Promise<Readonly<Record<string, unknown>>> {
|
|
42
|
-
const
|
|
56
|
+
const opened = await this.openClient();
|
|
57
|
+
|
|
58
|
+
requireDaemonFeatures(opened.features, required);
|
|
43
59
|
|
|
44
60
|
try {
|
|
45
|
-
return await client.sendRequest(m, p);
|
|
61
|
+
return await opened.client.sendRequest(m, p);
|
|
46
62
|
} catch (error) {
|
|
47
|
-
if (!this.closed.has(client) || !RETRYABLE_METHODS.has(m)) {
|
|
63
|
+
if (!this.closed.has(opened.client) || !RETRYABLE_METHODS.has(m)) {
|
|
48
64
|
throw error;
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
const fresh = await this.openClient();
|
|
52
68
|
|
|
53
|
-
|
|
69
|
+
requireDaemonFeatures(fresh.features, required);
|
|
70
|
+
|
|
71
|
+
return fresh.client.sendRequest(m, p);
|
|
54
72
|
}
|
|
55
73
|
}
|
|
56
74
|
|
|
75
|
+
// The features of the daemon the next request reaches, read from the
|
|
76
|
+
// handshake of the connection it rides.
|
|
77
|
+
async readFeatures(): Promise<ReadonlySet<DaemonFeature>> {
|
|
78
|
+
const opened = await this.openClient();
|
|
79
|
+
|
|
80
|
+
return opened.features;
|
|
81
|
+
}
|
|
82
|
+
|
|
57
83
|
async stop(): Promise<void> {
|
|
58
84
|
const current = this.client;
|
|
59
85
|
|
|
@@ -64,17 +90,19 @@ export class ReconnectingCaller implements FleetCaller {
|
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
try {
|
|
67
|
-
const
|
|
93
|
+
const opened = await current;
|
|
68
94
|
|
|
69
|
-
client.stop();
|
|
95
|
+
opened.client.stop();
|
|
70
96
|
} catch {
|
|
71
97
|
// A connection that never opened has nothing to close.
|
|
72
98
|
}
|
|
73
99
|
}
|
|
74
100
|
|
|
75
|
-
private openClient(): Promise<
|
|
101
|
+
private openClient(): Promise<DaemonConnection> {
|
|
76
102
|
if (this.client === null) {
|
|
77
|
-
const opening: Promise<
|
|
103
|
+
const opening: Promise<DaemonConnection> = this.openFreshClient(
|
|
104
|
+
() => this.client === opening,
|
|
105
|
+
);
|
|
78
106
|
|
|
79
107
|
this.client = opening;
|
|
80
108
|
}
|
|
@@ -85,7 +113,7 @@ export class ReconnectingCaller implements FleetCaller {
|
|
|
85
113
|
// isCurrent returns true while this connection is still the one later requests
|
|
86
114
|
// reuse: a connection that ends after a newer one replaced it must leave
|
|
87
115
|
// the newer one in place.
|
|
88
|
-
private async openFreshClient(isCurrent: () => boolean): Promise<
|
|
116
|
+
private async openFreshClient(isCurrent: () => boolean): Promise<DaemonConnection> {
|
|
89
117
|
const resetClient = () => {
|
|
90
118
|
if (isCurrent()) {
|
|
91
119
|
this.client = null;
|
|
@@ -107,8 +135,10 @@ export class ReconnectingCaller implements FleetCaller {
|
|
|
107
135
|
resetClient();
|
|
108
136
|
};
|
|
109
137
|
|
|
138
|
+
let hello: Readonly<Record<string, unknown>>;
|
|
139
|
+
|
|
110
140
|
try {
|
|
111
|
-
await client.sendHello(this.build);
|
|
141
|
+
hello = await client.sendHello(this.build);
|
|
112
142
|
} catch (error) {
|
|
113
143
|
client.stop();
|
|
114
144
|
|
|
@@ -116,6 +146,6 @@ export class ReconnectingCaller implements FleetCaller {
|
|
|
116
146
|
throw error;
|
|
117
147
|
}
|
|
118
148
|
|
|
119
|
-
return client;
|
|
149
|
+
return { client, features: parseDaemonFeatures(hello) };
|
|
120
150
|
}
|
|
121
151
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
2
|
+
|
|
3
|
+
// What each feature lets a tool call ask for, as an outdated-daemon refusal
|
|
4
|
+
// states it.
|
|
5
|
+
const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
6
|
+
'agents.list': 'atc_agents_list',
|
|
7
|
+
'events.more': "atc_events_read's more flag",
|
|
8
|
+
'events.session': "atc_events_read's session filter",
|
|
9
|
+
'message.turn': "atc_message_get's turn and answeredWith",
|
|
10
|
+
'message.wait': "atc_message_get's waitMs",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Throws a `daemon_outdated` error when the daemon a request is about to
|
|
15
|
+
* reach lacks a feature the request depends on, rather than letting a daemon
|
|
16
|
+
* that would ignore the option answer as if it had honoured it. atc never
|
|
17
|
+
* restarts the daemon itself: a restart is the operator's call, because it
|
|
18
|
+
* respawns every session.
|
|
19
|
+
*/
|
|
20
|
+
export function requireDaemonFeatures(
|
|
21
|
+
features: ReadonlySet<DaemonFeature>,
|
|
22
|
+
required: readonly DaemonFeature[],
|
|
23
|
+
): void {
|
|
24
|
+
const missing = required.find((feature) => !features.has(feature));
|
|
25
|
+
|
|
26
|
+
if (missing === undefined) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new Error(
|
|
31
|
+
`daemon_outdated: the running atc daemon is older than this atc and does not support ${FEATURE_USES[missing]}. Restart the daemon to use it: press u in the atc TUI, which restores every session. Until then, call without it.`,
|
|
32
|
+
);
|
|
33
|
+
}
|