@softov/ahpc 0.1.0 → 0.3.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 +69 -1
- package/dist/src/ahp/channels.js +10 -3
- package/dist/src/ahp/live.js +63 -6
- package/dist/src/ahp/publish.js +31 -6
- package/dist/src/cli/main.d.ts +1 -1
- package/dist/src/cli/main.js +100 -98
- package/dist/src/flags.js +7 -0
- package/dist/src/main.js +14 -2
- package/dist/src/mcp/http.d.ts +34 -0
- package/dist/src/mcp/http.js +247 -0
- package/dist/src/mcp/serve.d.ts +96 -0
- package/dist/src/mcp/serve.js +169 -0
- package/dist/src/mcp/stdio.d.ts +14 -0
- package/dist/src/mcp/stdio.js +64 -0
- package/dist/src/mcp/tools.d.ts +68 -0
- package/dist/src/mcp/tools.js +765 -0
- package/dist/src/tui.d.ts +1 -1
- package/dist/src/tui.js +1 -0
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +38 -0
- package/dist/src/wait.d.ts +56 -0
- package/dist/src/wait.js +160 -0
- package/package.json +1 -1
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What an agent somewhere else can do to a host through this client.
|
|
3
|
+
*
|
|
4
|
+
* One table, three surfaces. `stdio.ts` serves it to a client that launched
|
|
5
|
+
* this process, `http.ts` serves the same table over a socket as MCP and as a
|
|
6
|
+
* plain JSON API, and none of them holds any knowledge of what a tool does:
|
|
7
|
+
* everything below is a name, a JSON Schema, and a few lines against the
|
|
8
|
+
* `HostConnection` this client already opens.
|
|
9
|
+
*
|
|
10
|
+
* The schemas are written out rather than generated. They go on the wire as
|
|
11
|
+
* JSON Schema whatever produces them, this is the only place they exist, and
|
|
12
|
+
* a validator dependency to describe five objects would be a dependency for
|
|
13
|
+
* its own sake - which is the same reason `flags.ts` scans argv by hand.
|
|
14
|
+
*
|
|
15
|
+
* Which tools: the ones that let an agent drive a session to completion, and
|
|
16
|
+
* not the whole of what `ahpc` can do. A tool table is read by a model with
|
|
17
|
+
* everything else it has been given, so forty of them is a worse server than
|
|
18
|
+
* eleven. Files, terminals, automations and changesets are deliberately not
|
|
19
|
+
* here; see ROADMAP.md.
|
|
20
|
+
*/
|
|
21
|
+
import { SessionFlag } from '../ahp/types.js';
|
|
22
|
+
import { spoken, turn as runTurn, until } from '../wait.js';
|
|
23
|
+
/** Every group there is, for `--mcp-tools` to name in its help. */
|
|
24
|
+
export const GROUPS = ['resources', 'terminals', 'automations', 'changes'];
|
|
25
|
+
const text = (value) => (typeof value === 'string' ? value : '');
|
|
26
|
+
const uriOf = (input) => {
|
|
27
|
+
const found = text(input.session);
|
|
28
|
+
if (found === '')
|
|
29
|
+
throw new Error('No session was given. Every session tool takes one from list_sessions or new_session.');
|
|
30
|
+
return found;
|
|
31
|
+
};
|
|
32
|
+
/** A turn as an answer rather than a tree of parts, which is what a model reads. */
|
|
33
|
+
const said = (one) => ({
|
|
34
|
+
id: one.id,
|
|
35
|
+
role: one.role,
|
|
36
|
+
state: one.state,
|
|
37
|
+
at: one.at,
|
|
38
|
+
...(one.message === undefined ? {} : { message: one.message }),
|
|
39
|
+
text: spoken(one),
|
|
40
|
+
tools: one.parts
|
|
41
|
+
.filter((part) => part.kind === 'toolCall')
|
|
42
|
+
.map((part) => (part.kind === 'toolCall'
|
|
43
|
+
? { id: part.call.id, name: part.call.name, status: part.call.status }
|
|
44
|
+
: null))
|
|
45
|
+
.filter((one_) => one_ !== null),
|
|
46
|
+
});
|
|
47
|
+
/** The URI a resource tool was given, which is a `file://` on the host rather than a local path. */
|
|
48
|
+
const pathOf = (input, key = 'path') => {
|
|
49
|
+
const found = text(input[key]);
|
|
50
|
+
if (found === '')
|
|
51
|
+
throw new Error(`No ${key} was given. Resource tools take a file:// URI on the host, not a path on this machine.`);
|
|
52
|
+
return found;
|
|
53
|
+
};
|
|
54
|
+
/** A host half this connection may not have, or the reason it does not. */
|
|
55
|
+
function has(part, what) {
|
|
56
|
+
if (part === undefined)
|
|
57
|
+
throw new Error(`This host serves no ${what}. A host is given one, and this one was not.`);
|
|
58
|
+
return part;
|
|
59
|
+
}
|
|
60
|
+
export const TOOLS = [
|
|
61
|
+
{
|
|
62
|
+
name: 'list_sessions',
|
|
63
|
+
title: 'List sessions',
|
|
64
|
+
description: 'Every session on the connected host, newest first. Start here: every other session tool takes a session URI from this list.',
|
|
65
|
+
readOnly: true,
|
|
66
|
+
input: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
archived: { type: 'boolean', description: 'Include archived sessions. Off by default.' },
|
|
70
|
+
},
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
},
|
|
73
|
+
run: async (host, input) => {
|
|
74
|
+
const rows = await host.listSessions();
|
|
75
|
+
const wanted = input.archived === true
|
|
76
|
+
? rows
|
|
77
|
+
: rows.filter((row) => (row.status & SessionFlag.IsArchived) === 0);
|
|
78
|
+
return wanted.map((row) => ({
|
|
79
|
+
session: row.resource,
|
|
80
|
+
title: row.title,
|
|
81
|
+
provider: row.provider,
|
|
82
|
+
status: row.status,
|
|
83
|
+
modifiedAt: row.modifiedAt,
|
|
84
|
+
workingDirectories: row.workingDirectories,
|
|
85
|
+
...(row.activity === undefined ? {} : { activity: row.activity }),
|
|
86
|
+
}));
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'show_session',
|
|
91
|
+
title: 'Show one session',
|
|
92
|
+
description: 'What the host says about one session: its title, what it is doing, and the directories it works in.',
|
|
93
|
+
readOnly: true,
|
|
94
|
+
input: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: { session: { type: 'string', description: 'A session URI from list_sessions.' } },
|
|
97
|
+
required: ['session'],
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
},
|
|
100
|
+
run: async (host, input) => host.detail(uriOf(input)),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'list_agents',
|
|
104
|
+
title: 'List harnesses',
|
|
105
|
+
description: 'The harnesses this host serves and the models each offers. The provider name from here is what new_session takes.',
|
|
106
|
+
readOnly: true,
|
|
107
|
+
input: { type: 'object', properties: {}, additionalProperties: false },
|
|
108
|
+
run: async (host) => (await host.agents()).map((agent) => ({
|
|
109
|
+
provider: agent.provider,
|
|
110
|
+
name: agent.displayName,
|
|
111
|
+
...(agent.description === undefined ? {} : { description: agent.description }),
|
|
112
|
+
models: agent.models.map((model) => model.id),
|
|
113
|
+
})),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'new_session',
|
|
117
|
+
title: 'Start a session',
|
|
118
|
+
description: 'Create a session on the host and return its URI. The agent is not asked anything until send_turn.',
|
|
119
|
+
readOnly: false,
|
|
120
|
+
input: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
provider: { type: 'string', description: 'A provider from list_agents. The first one the host serves, if omitted.' },
|
|
124
|
+
workingDirectory: { type: 'string', description: 'An absolute path on the host for the agent to work in.' },
|
|
125
|
+
config: { type: 'object', description: 'Configuration values, as a flat object of strings.', additionalProperties: { type: 'string' } },
|
|
126
|
+
},
|
|
127
|
+
additionalProperties: false,
|
|
128
|
+
},
|
|
129
|
+
run: async (host, input) => {
|
|
130
|
+
const provider = text(input.provider) || (await host.agents())[0]?.provider;
|
|
131
|
+
if (provider === undefined)
|
|
132
|
+
throw new Error('This host advertises no harness to start a session on.');
|
|
133
|
+
const config = typeof input.config === 'object' && input.config !== null
|
|
134
|
+
? input.config
|
|
135
|
+
: undefined;
|
|
136
|
+
const where = text(input.workingDirectory);
|
|
137
|
+
return {
|
|
138
|
+
session: await host.createSession({
|
|
139
|
+
provider,
|
|
140
|
+
...(where === '' ? {} : { workingDirectory: where }),
|
|
141
|
+
...(config === undefined ? {} : { config }),
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'dispose_session',
|
|
148
|
+
title: 'End a session',
|
|
149
|
+
description: 'Dispose of a session. The transcript is the host\'s to keep or drop; the agent behind it stops.',
|
|
150
|
+
readOnly: false,
|
|
151
|
+
input: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: { session: { type: 'string' } },
|
|
154
|
+
required: ['session'],
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
},
|
|
157
|
+
run: async (host, input) => { await host.disposeSession(uriOf(input)); return { disposed: true }; },
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'session_history',
|
|
161
|
+
title: 'Read a transcript',
|
|
162
|
+
description: 'The turns in a session, oldest first, as text rather than as a tree of parts.',
|
|
163
|
+
readOnly: true,
|
|
164
|
+
input: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
session: { type: 'string' },
|
|
168
|
+
all: { type: 'boolean', description: 'Load the whole conversation rather than the window the host opened with.' },
|
|
169
|
+
},
|
|
170
|
+
required: ['session'],
|
|
171
|
+
additionalProperties: false,
|
|
172
|
+
},
|
|
173
|
+
run: async (host, input) => {
|
|
174
|
+
const uri = uriOf(input);
|
|
175
|
+
if (input.all === true) {
|
|
176
|
+
// Bounded, because a conversation somebody has been having for a year
|
|
177
|
+
// is one this would otherwise read to the end of before answering.
|
|
178
|
+
for (let page = 0; page < 100; page += 1) {
|
|
179
|
+
if (!await host.loadOlderTurns(uri))
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const event = await until(host, uri, (one) => one.type === 'snapshot', { timeoutSeconds: 30 });
|
|
184
|
+
if (event?.type !== 'snapshot')
|
|
185
|
+
throw new Error(`${uri} said nothing within thirty seconds.`);
|
|
186
|
+
return {
|
|
187
|
+
turns: event.turns.map(said),
|
|
188
|
+
...(event.active === undefined ? {} : { running: said(event.active) }),
|
|
189
|
+
...(event.input === undefined ? {} : { waitingOn: event.input }),
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'send_turn',
|
|
195
|
+
title: 'Say something and wait',
|
|
196
|
+
description: 'Say something to a session and block until the agent has finished answering, then return what it said. A turn that stops to ask a person something is not finished: this keeps waiting, and wait_for_attention is how to find out what it wants.',
|
|
197
|
+
readOnly: false,
|
|
198
|
+
input: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
properties: {
|
|
201
|
+
session: { type: 'string' },
|
|
202
|
+
text: { type: 'string', description: 'What to say.' },
|
|
203
|
+
model: { type: 'string', description: 'A model id from list_agents. The session\'s own, if omitted.' },
|
|
204
|
+
timeoutSeconds: { type: 'number', description: 'How long to wait. 900 by default.' },
|
|
205
|
+
},
|
|
206
|
+
required: ['session', 'text'],
|
|
207
|
+
additionalProperties: false,
|
|
208
|
+
},
|
|
209
|
+
run: async (host, input, report) => {
|
|
210
|
+
const model = text(input.model);
|
|
211
|
+
const answer = await runTurn(host, uriOf(input), text(input.text), {
|
|
212
|
+
...(model === '' ? {} : { model: { id: model } }),
|
|
213
|
+
...(typeof input.timeoutSeconds === 'number' ? { timeoutSeconds: input.timeoutSeconds } : {}),
|
|
214
|
+
/*
|
|
215
|
+
* What a caller watching this is told while it waits.
|
|
216
|
+
*
|
|
217
|
+
* The tool a session stopped on, not the reply as it is typed: MCP's
|
|
218
|
+
* progress carries a human-readable line and has no shape for partial
|
|
219
|
+
* result content, so the text still arrives whole at the end. What
|
|
220
|
+
* this fixes is an agent that looked frozen for a minute.
|
|
221
|
+
*/
|
|
222
|
+
...(report === undefined ? {} : {
|
|
223
|
+
onStep: (call) => report(call.name),
|
|
224
|
+
onWaiting: (call) => report(`waiting on ${call.name}`),
|
|
225
|
+
}),
|
|
226
|
+
});
|
|
227
|
+
// Not an error: the turn is still running and the session is still
|
|
228
|
+
// there, which is a different thing to tell a caller than a failure.
|
|
229
|
+
return answer === undefined ? { state: 'timeout' } : said(answer);
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'queue_turn',
|
|
234
|
+
title: 'Say something after this one',
|
|
235
|
+
description: 'Queue a message to be said once the running turn finishes. Returns at once, unlike send_turn.',
|
|
236
|
+
readOnly: false,
|
|
237
|
+
input: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: { session: { type: 'string' }, text: { type: 'string' } },
|
|
240
|
+
required: ['session', 'text'],
|
|
241
|
+
additionalProperties: false,
|
|
242
|
+
},
|
|
243
|
+
run: async (host, input) => { host.queue(uriOf(input), text(input.text)); return { queued: true }; },
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: 'cancel_turn',
|
|
247
|
+
title: 'Stop the running turn',
|
|
248
|
+
description: 'Interrupt whatever the agent is doing. What it had already said stays in the transcript.',
|
|
249
|
+
readOnly: false,
|
|
250
|
+
input: {
|
|
251
|
+
type: 'object',
|
|
252
|
+
properties: { session: { type: 'string' } },
|
|
253
|
+
required: ['session'],
|
|
254
|
+
additionalProperties: false,
|
|
255
|
+
},
|
|
256
|
+
run: async (host, input) => { host.stopTurn(uriOf(input)); return { cancelled: true }; },
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: 'wait_for_attention',
|
|
260
|
+
title: 'Wait until something wants a person',
|
|
261
|
+
description: 'Block until the session needs an answer - a tool call to approve, a question to fill in - or until it goes quiet, and say which. This is what makes a session drivable from here: without it a caller has no way to see what send_turn is waiting on.',
|
|
262
|
+
readOnly: true,
|
|
263
|
+
input: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {
|
|
266
|
+
session: { type: 'string' },
|
|
267
|
+
until: { type: 'string', description: 'input, the default, waits for something that wants a person; idle waits for the session to go quiet.' },
|
|
268
|
+
timeoutSeconds: { type: 'number', description: '300 by default.' },
|
|
269
|
+
},
|
|
270
|
+
required: ['session'],
|
|
271
|
+
additionalProperties: false,
|
|
272
|
+
},
|
|
273
|
+
run: async (host, input) => {
|
|
274
|
+
const stop = text(input.until) || 'input';
|
|
275
|
+
/*
|
|
276
|
+
* Either vocabulary, as `wait.ts` does.
|
|
277
|
+
*
|
|
278
|
+
* A host may rebuild the whole view and send a snapshot, or say what
|
|
279
|
+
* changed - `inputNeeded` and `turnComplete`. Reading only snapshots
|
|
280
|
+
* waits for ever on the second kind.
|
|
281
|
+
*/
|
|
282
|
+
const event = await until(host, uriOf(input), (one) => {
|
|
283
|
+
if (stop === 'idle') {
|
|
284
|
+
if (one.type === 'turnComplete')
|
|
285
|
+
return true;
|
|
286
|
+
return one.type === 'snapshot' && one.active === undefined && one.input === undefined;
|
|
287
|
+
}
|
|
288
|
+
if (one.type === 'inputNeeded')
|
|
289
|
+
return true;
|
|
290
|
+
return one.type === 'snapshot' && one.input !== undefined;
|
|
291
|
+
}, { timeoutSeconds: typeof input.timeoutSeconds === 'number' ? input.timeoutSeconds : 300 });
|
|
292
|
+
if (event === undefined)
|
|
293
|
+
return { state: 'timeout' };
|
|
294
|
+
if (event.type === 'inputNeeded')
|
|
295
|
+
return { state: 'waiting', waitingOn: event.input };
|
|
296
|
+
if (event.type === 'turnComplete')
|
|
297
|
+
return { state: 'idle', finished: said(event.turn) };
|
|
298
|
+
if (event.type !== 'snapshot')
|
|
299
|
+
return { state: 'timeout' };
|
|
300
|
+
return {
|
|
301
|
+
state: event.input === undefined ? 'idle' : 'waiting',
|
|
302
|
+
...(event.input === undefined ? {} : { waitingOn: event.input }),
|
|
303
|
+
...(event.active === undefined ? {} : { running: said(event.active) }),
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: 'confirm_tool_call',
|
|
309
|
+
title: 'Approve or deny a tool call',
|
|
310
|
+
description: 'Answer a tool call the agent is blocked on. The id comes from wait_for_attention.',
|
|
311
|
+
readOnly: false,
|
|
312
|
+
input: {
|
|
313
|
+
type: 'object',
|
|
314
|
+
properties: {
|
|
315
|
+
session: { type: 'string' },
|
|
316
|
+
toolCallId: { type: 'string' },
|
|
317
|
+
approved: { type: 'boolean', description: 'True to allow it, false to refuse.' },
|
|
318
|
+
optionId: { type: 'string', description: 'One of the options the request offered, where it offered any.' },
|
|
319
|
+
},
|
|
320
|
+
required: ['session', 'toolCallId', 'approved'],
|
|
321
|
+
additionalProperties: false,
|
|
322
|
+
},
|
|
323
|
+
run: async (host, input) => {
|
|
324
|
+
const option = text(input.optionId);
|
|
325
|
+
host.confirmToolCall(uriOf(input), text(input.toolCallId), input.approved === true, option === '' ? undefined : option);
|
|
326
|
+
return { answered: true };
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
name: 'answer_question',
|
|
331
|
+
title: 'Answer what the agent asked',
|
|
332
|
+
description: 'Fill in a question the agent put to a person. The request id and the fields come from wait_for_attention.',
|
|
333
|
+
readOnly: false,
|
|
334
|
+
input: {
|
|
335
|
+
type: 'object',
|
|
336
|
+
properties: {
|
|
337
|
+
session: { type: 'string' },
|
|
338
|
+
requestId: { type: 'string' },
|
|
339
|
+
answers: { type: 'object', description: 'The answers, keyed by question id.', additionalProperties: true },
|
|
340
|
+
reject: { type: 'boolean', description: 'Decline to answer instead.' },
|
|
341
|
+
},
|
|
342
|
+
required: ['session', 'requestId'],
|
|
343
|
+
additionalProperties: false,
|
|
344
|
+
},
|
|
345
|
+
run: async (host, input) => {
|
|
346
|
+
const answers = typeof input.answers === 'object' && input.answers !== null
|
|
347
|
+
? input.answers
|
|
348
|
+
: {};
|
|
349
|
+
host.completeInput(uriOf(input), text(input.requestId), input.reject !== true, answers);
|
|
350
|
+
return { answered: true };
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
/*
|
|
354
|
+
* The files the host serves, which is the `resources` group.
|
|
355
|
+
*
|
|
356
|
+
* Every one of these takes a `file://` URI on the *host*, not a path here -
|
|
357
|
+
* the host may be on another machine, and a tool that quietly resolved a
|
|
358
|
+
* relative path against this process's directory would be wrong in a way
|
|
359
|
+
* nobody notices until it writes somewhere.
|
|
360
|
+
*/
|
|
361
|
+
{
|
|
362
|
+
name: 'list_directory',
|
|
363
|
+
group: 'resources',
|
|
364
|
+
title: 'List a directory',
|
|
365
|
+
description: 'What is in a directory the host serves. Takes a file:// URI on the host.',
|
|
366
|
+
readOnly: true,
|
|
367
|
+
input: {
|
|
368
|
+
type: 'object',
|
|
369
|
+
properties: { path: { type: 'string', description: 'A file:// URI of a directory on the host.' } },
|
|
370
|
+
required: ['path'],
|
|
371
|
+
additionalProperties: false,
|
|
372
|
+
},
|
|
373
|
+
run: async (host, input) => has(host.resourceList, 'filesystem')(pathOf(input)),
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: 'read_file',
|
|
377
|
+
group: 'resources',
|
|
378
|
+
title: 'Read a file',
|
|
379
|
+
description: 'The contents of a file the host serves. Text comes back as text; anything the host sends as bytes comes back base64 with the encoding said.',
|
|
380
|
+
readOnly: true,
|
|
381
|
+
input: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
properties: { path: { type: 'string', description: 'A file:// URI on the host.' } },
|
|
384
|
+
required: ['path'],
|
|
385
|
+
additionalProperties: false,
|
|
386
|
+
},
|
|
387
|
+
run: async (host, input) => has(host.resourceRead, 'filesystem')(pathOf(input)),
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: 'write_file',
|
|
391
|
+
group: 'resources',
|
|
392
|
+
title: 'Write a file',
|
|
393
|
+
description: 'Write a file on the host, refusing if it changed since it was read. Pass force to write over whatever is there now. A host that has not granted write access to that directory refuses this, and only a person at the host can grant it.',
|
|
394
|
+
readOnly: false,
|
|
395
|
+
input: {
|
|
396
|
+
type: 'object',
|
|
397
|
+
properties: {
|
|
398
|
+
path: { type: 'string', description: 'A file:// URI on the host.' },
|
|
399
|
+
content: { type: 'string', description: 'The whole new contents. This replaces the file.' },
|
|
400
|
+
createOnly: { type: 'boolean', description: 'Refuse if the file already exists.' },
|
|
401
|
+
force: { type: 'boolean', description: 'Write even if the file changed since it was last read.' },
|
|
402
|
+
},
|
|
403
|
+
required: ['path', 'content'],
|
|
404
|
+
additionalProperties: false,
|
|
405
|
+
},
|
|
406
|
+
run: async (host, input) => {
|
|
407
|
+
const write = has(host.resourceWrite, 'writable filesystem');
|
|
408
|
+
const uri = pathOf(input);
|
|
409
|
+
/*
|
|
410
|
+
* The etag the file has now, unless told not to.
|
|
411
|
+
*
|
|
412
|
+
* The same guard `ahpc resource write` has, and it matters more here: a
|
|
413
|
+
* model reads a file, thinks about it, and writes it back, and the whole
|
|
414
|
+
* of that is a read-modify-write with a person editing in between. A
|
|
415
|
+
* write with no `ifMatch` lands on whatever is there and loses their edit.
|
|
416
|
+
*/
|
|
417
|
+
let ifMatch;
|
|
418
|
+
if (input.force !== true && host.resourceResolve) {
|
|
419
|
+
try {
|
|
420
|
+
ifMatch = (await host.resourceResolve(uri)).etag;
|
|
421
|
+
}
|
|
422
|
+
catch { /* not there yet, so there is nothing to have changed */ }
|
|
423
|
+
}
|
|
424
|
+
await write(uri, text(input.content), {
|
|
425
|
+
...(input.createOnly === true ? { createOnly: true } : {}),
|
|
426
|
+
...(ifMatch === undefined ? {} : { ifMatch }),
|
|
427
|
+
});
|
|
428
|
+
return { written: uri };
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: 'make_directory',
|
|
433
|
+
group: 'resources',
|
|
434
|
+
title: 'Make a directory',
|
|
435
|
+
description: 'Create a directory on the host.',
|
|
436
|
+
readOnly: false,
|
|
437
|
+
input: {
|
|
438
|
+
type: 'object',
|
|
439
|
+
properties: { path: { type: 'string' } },
|
|
440
|
+
required: ['path'],
|
|
441
|
+
additionalProperties: false,
|
|
442
|
+
},
|
|
443
|
+
run: async (host, input) => {
|
|
444
|
+
await has(host.resourceMkdir, 'writable filesystem')(pathOf(input));
|
|
445
|
+
return { made: pathOf(input) };
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: 'delete_path',
|
|
450
|
+
group: 'resources',
|
|
451
|
+
title: 'Delete a file or directory',
|
|
452
|
+
description: 'Remove something on the host. A directory needs recursive.',
|
|
453
|
+
readOnly: false,
|
|
454
|
+
input: {
|
|
455
|
+
type: 'object',
|
|
456
|
+
properties: {
|
|
457
|
+
path: { type: 'string' },
|
|
458
|
+
recursive: { type: 'boolean', description: 'Needed to remove a directory that is not empty.' },
|
|
459
|
+
},
|
|
460
|
+
required: ['path'],
|
|
461
|
+
additionalProperties: false,
|
|
462
|
+
},
|
|
463
|
+
run: async (host, input) => {
|
|
464
|
+
await has(host.resourceDelete, 'writable filesystem')(pathOf(input), {
|
|
465
|
+
...(input.recursive === true ? { recursive: true } : {}),
|
|
466
|
+
});
|
|
467
|
+
return { deleted: pathOf(input) };
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
name: 'move_path',
|
|
472
|
+
group: 'resources',
|
|
473
|
+
title: 'Move or rename',
|
|
474
|
+
description: 'Move something on the host, which is also how it is renamed.',
|
|
475
|
+
readOnly: false,
|
|
476
|
+
input: {
|
|
477
|
+
type: 'object',
|
|
478
|
+
properties: {
|
|
479
|
+
from: { type: 'string' },
|
|
480
|
+
to: { type: 'string' },
|
|
481
|
+
failIfExists: { type: 'boolean', description: 'Refuse rather than write over something already at the destination.' },
|
|
482
|
+
},
|
|
483
|
+
required: ['from', 'to'],
|
|
484
|
+
additionalProperties: false,
|
|
485
|
+
},
|
|
486
|
+
run: async (host, input) => {
|
|
487
|
+
await has(host.resourceMove, 'writable filesystem')(pathOf(input, 'from'), pathOf(input, 'to'), {
|
|
488
|
+
...(input.failIfExists === true ? { failIfExists: true } : {}),
|
|
489
|
+
});
|
|
490
|
+
return { moved: pathOf(input, 'to') };
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
name: 'copy_path',
|
|
495
|
+
group: 'resources',
|
|
496
|
+
title: 'Copy',
|
|
497
|
+
description: 'Copy something on the host.',
|
|
498
|
+
readOnly: false,
|
|
499
|
+
input: {
|
|
500
|
+
type: 'object',
|
|
501
|
+
properties: {
|
|
502
|
+
from: { type: 'string' },
|
|
503
|
+
to: { type: 'string' },
|
|
504
|
+
failIfExists: { type: 'boolean', description: 'Refuse rather than write over something already at the destination.' },
|
|
505
|
+
},
|
|
506
|
+
required: ['from', 'to'],
|
|
507
|
+
additionalProperties: false,
|
|
508
|
+
},
|
|
509
|
+
run: async (host, input) => {
|
|
510
|
+
await has(host.resourceCopy, 'writable filesystem')(pathOf(input, 'from'), pathOf(input, 'to'), {
|
|
511
|
+
...(input.failIfExists === true ? { failIfExists: true } : {}),
|
|
512
|
+
});
|
|
513
|
+
return { copied: pathOf(input, 'to') };
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
/* The host's terminals, which is the `terminals` group. */
|
|
517
|
+
{
|
|
518
|
+
name: 'list_terminals',
|
|
519
|
+
group: 'terminals',
|
|
520
|
+
title: 'List terminals',
|
|
521
|
+
description: 'The terminals the host is running, with the URI each other terminal tool takes.',
|
|
522
|
+
readOnly: true,
|
|
523
|
+
input: { type: 'object', properties: {}, additionalProperties: false },
|
|
524
|
+
run: async (host) => (await host.terminals()).map((row) => ({
|
|
525
|
+
terminal: row.resource,
|
|
526
|
+
title: row.title,
|
|
527
|
+
...(row.exitCode === undefined ? {} : { exitCode: row.exitCode }),
|
|
528
|
+
})),
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
name: 'new_terminal',
|
|
532
|
+
group: 'terminals',
|
|
533
|
+
title: 'Open a terminal',
|
|
534
|
+
description: 'Start a terminal on the host and return its URI.',
|
|
535
|
+
readOnly: false,
|
|
536
|
+
input: {
|
|
537
|
+
type: 'object',
|
|
538
|
+
properties: {
|
|
539
|
+
workingDirectory: { type: 'string', description: 'An absolute path on the host.' },
|
|
540
|
+
name: { type: 'string' },
|
|
541
|
+
},
|
|
542
|
+
additionalProperties: false,
|
|
543
|
+
},
|
|
544
|
+
run: async (host, input) => ({
|
|
545
|
+
terminal: await host.createTerminal({
|
|
546
|
+
...(text(input.workingDirectory) === '' ? {} : { cwd: text(input.workingDirectory) }),
|
|
547
|
+
...(text(input.name) === '' ? {} : { name: text(input.name) }),
|
|
548
|
+
}),
|
|
549
|
+
}),
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: 'send_to_terminal',
|
|
553
|
+
group: 'terminals',
|
|
554
|
+
title: 'Type into a terminal',
|
|
555
|
+
description: 'Send a line to a terminal. A newline is added unless newline is false, because a shell runs lines rather than strings.',
|
|
556
|
+
readOnly: false,
|
|
557
|
+
input: {
|
|
558
|
+
type: 'object',
|
|
559
|
+
properties: {
|
|
560
|
+
terminal: { type: 'string', description: 'A terminal URI from list_terminals or new_terminal.' },
|
|
561
|
+
text: { type: 'string' },
|
|
562
|
+
newline: { type: 'boolean', description: 'Whether to end it with a newline. True unless said otherwise.' },
|
|
563
|
+
},
|
|
564
|
+
required: ['terminal', 'text'],
|
|
565
|
+
additionalProperties: false,
|
|
566
|
+
},
|
|
567
|
+
run: async (host, input) => {
|
|
568
|
+
host.writeTerminal(pathOf(input, 'terminal'), `${text(input.text)}${input.newline === false ? '' : '\n'}`);
|
|
569
|
+
return { sent: true };
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
name: 'read_terminal',
|
|
574
|
+
group: 'terminals',
|
|
575
|
+
title: 'Read a terminal',
|
|
576
|
+
description: 'What a terminal has written so far. With waitSeconds it keeps reading until the process exits or that long passes, which is how a command that was just sent is waited on.',
|
|
577
|
+
readOnly: true,
|
|
578
|
+
input: {
|
|
579
|
+
type: 'object',
|
|
580
|
+
properties: {
|
|
581
|
+
terminal: { type: 'string' },
|
|
582
|
+
waitSeconds: { type: 'number', description: 'Wait this long for the process to exit before answering. Zero, the default, answers with what is there now.' },
|
|
583
|
+
},
|
|
584
|
+
required: ['terminal'],
|
|
585
|
+
additionalProperties: false,
|
|
586
|
+
},
|
|
587
|
+
run: async (host, input, report) => {
|
|
588
|
+
const uri = pathOf(input, 'terminal');
|
|
589
|
+
const seconds = typeof input.waitSeconds === 'number' && input.waitSeconds > 0 ? input.waitSeconds : 0;
|
|
590
|
+
return new Promise((done) => {
|
|
591
|
+
let last;
|
|
592
|
+
const stop = () => {
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
handle.close();
|
|
595
|
+
done({
|
|
596
|
+
terminal: uri,
|
|
597
|
+
title: last?.title ?? '',
|
|
598
|
+
output: last?.output ?? '',
|
|
599
|
+
...(last?.exitCode === undefined ? { running: true } : { exitCode: last.exitCode }),
|
|
600
|
+
});
|
|
601
|
+
};
|
|
602
|
+
const timer = setTimeout(stop, Math.max(0, seconds) * 1000);
|
|
603
|
+
timer.unref?.();
|
|
604
|
+
const handle = host.watchTerminal(uri, (state) => {
|
|
605
|
+
last = state;
|
|
606
|
+
report?.(state.exitCode === undefined ? `${state.output.length} bytes` : `exited ${state.exitCode}`);
|
|
607
|
+
// The first state carries the whole buffer, so a caller that is not
|
|
608
|
+
// waiting has its answer as soon as one arrives.
|
|
609
|
+
if (seconds === 0 || state.exitCode !== undefined)
|
|
610
|
+
stop();
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
name: 'dispose_terminal',
|
|
617
|
+
group: 'terminals',
|
|
618
|
+
title: 'Close a terminal',
|
|
619
|
+
description: 'Close a terminal on the host.',
|
|
620
|
+
readOnly: false,
|
|
621
|
+
input: {
|
|
622
|
+
type: 'object',
|
|
623
|
+
properties: { terminal: { type: 'string' } },
|
|
624
|
+
required: ['terminal'],
|
|
625
|
+
additionalProperties: false,
|
|
626
|
+
},
|
|
627
|
+
run: async (host, input) => {
|
|
628
|
+
await host.disposeTerminal(pathOf(input, 'terminal'));
|
|
629
|
+
return { disposed: true };
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
/* Scheduled work, which is the `automations` group. */
|
|
633
|
+
{
|
|
634
|
+
name: 'list_automations',
|
|
635
|
+
group: 'automations',
|
|
636
|
+
title: 'List automations',
|
|
637
|
+
description: 'What the host runs on a schedule, whether each is on, and when it next fires.',
|
|
638
|
+
readOnly: true,
|
|
639
|
+
input: { type: 'object', properties: {}, additionalProperties: false },
|
|
640
|
+
run: async (host) => (await has(host.automations, 'automations')()).map((one) => ({
|
|
641
|
+
automation: one.resource,
|
|
642
|
+
title: one.title,
|
|
643
|
+
enabled: one.enabled,
|
|
644
|
+
...(one.schedule === undefined ? {} : { schedule: one.schedule.expression, timeZone: one.schedule.timeZone }),
|
|
645
|
+
...(one.nextRunAt === undefined ? {} : { nextRunAt: one.nextRunAt }),
|
|
646
|
+
operations: one.operations,
|
|
647
|
+
lastRuns: one.runs.slice(0, 5),
|
|
648
|
+
})),
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: 'run_automation',
|
|
652
|
+
group: 'automations',
|
|
653
|
+
title: 'Run an automation',
|
|
654
|
+
description: 'Fire an automation now, without waiting for its schedule. Answers once the host has taken it, not once it has finished.',
|
|
655
|
+
readOnly: false,
|
|
656
|
+
input: {
|
|
657
|
+
type: 'object',
|
|
658
|
+
properties: { automation: { type: 'string', description: 'An automation URI from list_automations.' } },
|
|
659
|
+
required: ['automation'],
|
|
660
|
+
additionalProperties: false,
|
|
661
|
+
},
|
|
662
|
+
run: async (host, input) => {
|
|
663
|
+
await has(host.runAutomation, 'automations')(pathOf(input, 'automation'));
|
|
664
|
+
return { started: true };
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
name: 'set_automation_enabled',
|
|
669
|
+
group: 'automations',
|
|
670
|
+
title: 'Turn an automation on or off',
|
|
671
|
+
description: 'Stop an automation firing, or start it again. The definition stays either way.',
|
|
672
|
+
readOnly: false,
|
|
673
|
+
input: {
|
|
674
|
+
type: 'object',
|
|
675
|
+
properties: { automation: { type: 'string' }, enabled: { type: 'boolean' } },
|
|
676
|
+
required: ['automation', 'enabled'],
|
|
677
|
+
additionalProperties: false,
|
|
678
|
+
},
|
|
679
|
+
run: async (host, input) => {
|
|
680
|
+
await has(host.setAutomationEnabled, 'automations')(pathOf(input, 'automation'), input.enabled === true);
|
|
681
|
+
return { enabled: input.enabled === true };
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
{
|
|
685
|
+
name: 'remove_automation',
|
|
686
|
+
group: 'automations',
|
|
687
|
+
title: 'Remove an automation',
|
|
688
|
+
description: 'Delete an automation from the host. Use set_automation_enabled to stop one without losing it.',
|
|
689
|
+
readOnly: false,
|
|
690
|
+
input: {
|
|
691
|
+
type: 'object',
|
|
692
|
+
properties: { automation: { type: 'string' } },
|
|
693
|
+
required: ['automation'],
|
|
694
|
+
additionalProperties: false,
|
|
695
|
+
},
|
|
696
|
+
run: async (host, input) => {
|
|
697
|
+
await has(host.removeAutomation, 'automations')(pathOf(input, 'automation'));
|
|
698
|
+
return { removed: true };
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
/* What a session changed, which is the `changes` group. */
|
|
702
|
+
{
|
|
703
|
+
name: 'list_changesets',
|
|
704
|
+
group: 'changes',
|
|
705
|
+
title: 'List changesets',
|
|
706
|
+
description: 'The changesets a session offers - what the conversation changed, what one turn changed, what the working tree has. Each is a URI show_changes takes.',
|
|
707
|
+
readOnly: true,
|
|
708
|
+
input: {
|
|
709
|
+
type: 'object',
|
|
710
|
+
properties: { session: { type: 'string' } },
|
|
711
|
+
required: ['session'],
|
|
712
|
+
additionalProperties: false,
|
|
713
|
+
},
|
|
714
|
+
run: async (host, input) => (await has(host.changesets, 'changesets')(uriOf(input))).map((scope) => ({
|
|
715
|
+
changeset: scope.uriTemplate,
|
|
716
|
+
label: scope.label,
|
|
717
|
+
...(scope.description === undefined ? {} : { description: scope.description }),
|
|
718
|
+
// What is still to be filled in. A template with these left in it is not
|
|
719
|
+
// a URI yet, and saying so is better than the host refusing it later.
|
|
720
|
+
variables: scope.variables,
|
|
721
|
+
})),
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
name: 'show_changes',
|
|
725
|
+
group: 'changes',
|
|
726
|
+
title: 'Show a changeset',
|
|
727
|
+
description: 'The files in a changeset and how much each changed. The contents are not here: a changeset of two hundred files is a list worth having and megabytes that are not. Read one with read_file.',
|
|
728
|
+
readOnly: true,
|
|
729
|
+
input: {
|
|
730
|
+
type: 'object',
|
|
731
|
+
properties: {
|
|
732
|
+
session: { type: 'string' },
|
|
733
|
+
changeset: { type: 'string', description: 'A changeset URI from list_changesets. The session\'s own, if omitted.' },
|
|
734
|
+
},
|
|
735
|
+
required: ['session'],
|
|
736
|
+
additionalProperties: false,
|
|
737
|
+
},
|
|
738
|
+
run: async (host, input) => {
|
|
739
|
+
const target = text(input.changeset);
|
|
740
|
+
const found = await host.changes(uriOf(input), target === '' ? undefined : target);
|
|
741
|
+
return {
|
|
742
|
+
status: found.status,
|
|
743
|
+
files: found.files.map((file) => ({
|
|
744
|
+
uri: file.uri,
|
|
745
|
+
added: file.diff.added,
|
|
746
|
+
removed: file.diff.removed,
|
|
747
|
+
...(file.before === undefined ? { created: true } : {}),
|
|
748
|
+
...(file.after === undefined ? { deleted: true } : {}),
|
|
749
|
+
})),
|
|
750
|
+
operations: (found.operations ?? []).map((op) => op.id),
|
|
751
|
+
};
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
];
|
|
755
|
+
/**
|
|
756
|
+
* One tool by the name a caller used, or nothing.
|
|
757
|
+
*
|
|
758
|
+
* Over the whole table, including groups nobody turned on - whether a tool
|
|
759
|
+
* exists and whether this server serves it are different questions, and
|
|
760
|
+
* `served` answers the second. Telling somebody the tool is in a group they
|
|
761
|
+
* did not ask for is a better answer than telling them it does not exist.
|
|
762
|
+
*/
|
|
763
|
+
export const named = (name) => TOOLS.find((one) => one.name === name);
|
|
764
|
+
/** The tools a server started with these groups serves. */
|
|
765
|
+
export const served = (groups = []) => TOOLS.filter((one) => one.group === undefined || groups.includes(one.group));
|