@softov/ahpc 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/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/src/ahp/channels.d.ts +146 -0
- package/dist/src/ahp/channels.js +423 -0
- package/dist/src/ahp/connection.d.ts +656 -0
- package/dist/src/ahp/connection.js +1 -0
- package/dist/src/ahp/fake.d.ts +69 -0
- package/dist/src/ahp/fake.js +1996 -0
- package/dist/src/ahp/live.d.ts +160 -0
- package/dist/src/ahp/live.js +2641 -0
- package/dist/src/ahp/operate.d.ts +36 -0
- package/dist/src/ahp/operate.js +56 -0
- package/dist/src/ahp/publish.d.ts +39 -0
- package/dist/src/ahp/publish.js +370 -0
- package/dist/src/ahp/status.d.ts +23 -0
- package/dist/src/ahp/status.js +23 -0
- package/dist/src/ahp/types.d.ts +745 -0
- package/dist/src/ahp/types.js +27 -0
- package/dist/src/app.d.ts +54 -0
- package/dist/src/app.js +369 -0
- package/dist/src/blocks.d.ts +88 -0
- package/dist/src/blocks.js +102 -0
- package/dist/src/cli/main.d.ts +13 -0
- package/dist/src/cli/main.js +1537 -0
- package/dist/src/cli/render.d.ts +43 -0
- package/dist/src/cli/render.js +91 -0
- package/dist/src/config.d.ts +63 -0
- package/dist/src/config.js +45 -0
- package/dist/src/connect.d.ts +53 -0
- package/dist/src/connect.js +69 -0
- package/dist/src/control.d.ts +189 -0
- package/dist/src/control.js +2241 -0
- package/dist/src/diff.d.ts +44 -0
- package/dist/src/diff.js +111 -0
- package/dist/src/flags.d.ts +28 -0
- package/dist/src/flags.js +76 -0
- package/dist/src/main.d.ts +10 -0
- package/dist/src/main.js +54 -0
- package/dist/src/schedule.d.ts +46 -0
- package/dist/src/schedule.js +140 -0
- package/dist/src/screens.d.ts +79 -0
- package/dist/src/screens.js +1322 -0
- package/dist/src/state.d.ts +355 -0
- package/dist/src/state.js +531 -0
- package/dist/src/terminal.d.ts +44 -0
- package/dist/src/terminal.js +101 -0
- package/dist/src/tui.d.ts +92 -0
- package/dist/src/tui.js +372 -0
- package/dist/src/view/automations.d.ts +31 -0
- package/dist/src/view/automations.js +76 -0
- package/dist/src/view/bood/art.d.ts +90 -0
- package/dist/src/view/bood/art.js +152 -0
- package/dist/src/view/bood/beetle.d.ts +7 -0
- package/dist/src/view/bood/beetle.js +123 -0
- package/dist/src/view/bood/bunny.d.ts +8 -0
- package/dist/src/view/bood/bunny.js +228 -0
- package/dist/src/view/bood/cat.d.ts +9 -0
- package/dist/src/view/bood/cat.js +358 -0
- package/dist/src/view/bood/crab.d.ts +7 -0
- package/dist/src/view/bood/crab.js +121 -0
- package/dist/src/view/bood/index.d.ts +29 -0
- package/dist/src/view/bood/index.js +25 -0
- package/dist/src/view/bood/motion.d.ts +178 -0
- package/dist/src/view/bood/motion.js +298 -0
- package/dist/src/view/bood/owl.d.ts +9 -0
- package/dist/src/view/bood/owl.js +285 -0
- package/dist/src/view/bood/registry.d.ts +36 -0
- package/dist/src/view/bood/registry.js +218 -0
- package/dist/src/view/bood/render.d.ts +31 -0
- package/dist/src/view/bood/render.js +196 -0
- package/dist/src/view/bood/sprite.d.ts +18 -0
- package/dist/src/view/bood/sprite.js +130 -0
- package/dist/src/view/bood/sprout.d.ts +8 -0
- package/dist/src/view/bood/sprout.js +121 -0
- package/dist/src/view/bood/types.d.ts +158 -0
- package/dist/src/view/bood/types.js +57 -0
- package/dist/src/view/bubble.d.ts +75 -0
- package/dist/src/view/bubble.js +86 -0
- package/dist/src/view/changes.d.ts +35 -0
- package/dist/src/view/changes.js +22 -0
- package/dist/src/view/composer.d.ts +64 -0
- package/dist/src/view/composer.js +192 -0
- package/dist/src/view/controls.d.ts +44 -0
- package/dist/src/view/controls.js +49 -0
- package/dist/src/view/creature.d.ts +69 -0
- package/dist/src/view/creature.js +83 -0
- package/dist/src/view/customizations.d.ts +28 -0
- package/dist/src/view/customizations.js +53 -0
- package/dist/src/view/details.d.ts +65 -0
- package/dist/src/view/details.js +65 -0
- package/dist/src/view/filediff.d.ts +29 -0
- package/dist/src/view/filediff.js +24 -0
- package/dist/src/view/files.d.ts +31 -0
- package/dist/src/view/files.js +39 -0
- package/dist/src/view/hitl.d.ts +43 -0
- package/dist/src/view/hitl.js +171 -0
- package/dist/src/view/icons.d.ts +13 -0
- package/dist/src/view/icons.js +71 -0
- package/dist/src/view/picker.d.ts +42 -0
- package/dist/src/view/picker.js +71 -0
- package/dist/src/view/sessionhead.d.ts +41 -0
- package/dist/src/view/sessionhead.js +60 -0
- package/dist/src/view/sessions.d.ts +34 -0
- package/dist/src/view/sessions.js +61 -0
- package/dist/src/view/terminal.d.ts +30 -0
- package/dist/src/view/terminal.js +32 -0
- package/dist/src/view/toolcall.d.ts +27 -0
- package/dist/src/view/toolcall.js +48 -0
- package/dist/src/view/transcript.d.ts +50 -0
- package/dist/src/view/transcript.js +60 -0
- package/package.json +72 -0
|
@@ -0,0 +1,1537 @@
|
|
|
1
|
+
/** Every command, and the argv reading that picks one. */
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { connect } from '../connect.js';
|
|
4
|
+
import { configPath, loadConfig } from '../config.js';
|
|
5
|
+
import { ago, archived, branch, json, line, mark, project, table } from './render.js';
|
|
6
|
+
import { operate } from '../ahp/operate.js';
|
|
7
|
+
import { SWITCHES } from '../flags.js';
|
|
8
|
+
export const HELP = `ahpc - drive an agent host from a shell
|
|
9
|
+
|
|
10
|
+
ahpc [--host ws://…] <command> [args] the screen is 'ahpc' with no command
|
|
11
|
+
|
|
12
|
+
Sessions
|
|
13
|
+
session list the catalogue, newest first [--archived] [--json]
|
|
14
|
+
session show <uri> what the host says about one [--full] [--json]
|
|
15
|
+
session new start one [--agent P] [--cwd DIR] [--set k=v]… [--json]
|
|
16
|
+
session rm <uri> dispose it
|
|
17
|
+
session history <uri> its turns [--all] [--full] [--json]
|
|
18
|
+
session config <uri> the schema and what is in force [--json]
|
|
19
|
+
session set <uri> <k> <v> change one config key
|
|
20
|
+
session read <uri> mark read [--unread]
|
|
21
|
+
session archive <uri> put it away [--undo]
|
|
22
|
+
session customizations <uri> skills, prompts, agents, servers [--json]
|
|
23
|
+
session export <uri> the whole session as one document
|
|
24
|
+
[--json] [--markdown]
|
|
25
|
+
session toggle <uri> <id> turn one on [--off]
|
|
26
|
+
|
|
27
|
+
Turns
|
|
28
|
+
prompt <uri> <text> say it and stream the answer [--model M] [--json]
|
|
29
|
+
exec <text> a session, one turn, and dispose it
|
|
30
|
+
[--agent P] [--cwd DIR] [--model M] [--json]
|
|
31
|
+
cancel <uri> stop the running turn
|
|
32
|
+
queue <uri> <text> say it after the one running [--model M]
|
|
33
|
+
unqueue <uri> <id> take it back
|
|
34
|
+
|
|
35
|
+
Answering
|
|
36
|
+
watch <uri> BLOCK until something wants a person, print, exit
|
|
37
|
+
[--until turn|input|idle] [--timeout S] [--json]
|
|
38
|
+
confirm <uri> <toolCallId> approve a tool call [--deny] [--option ID]
|
|
39
|
+
answer <uri> <requestId> answer a question [--field k=v]… [--reject]
|
|
40
|
+
|
|
41
|
+
Chats
|
|
42
|
+
chat list <uri> the conversations in a session [--json]
|
|
43
|
+
chat new <uri> [text] another one beside it
|
|
44
|
+
chat rm <chatUri> close one
|
|
45
|
+
|
|
46
|
+
The harness
|
|
47
|
+
agents what it serves, and each one's models [--json]
|
|
48
|
+
models every model, by harness [--json]
|
|
49
|
+
commands what a slash offers [--json]
|
|
50
|
+
customizations skills, prompts, agents and MCP servers,
|
|
51
|
+
before any session exists [--kind k] [--json]
|
|
52
|
+
completions <uri> <text> what the host would complete [--offset N] [--json]
|
|
53
|
+
|
|
54
|
+
Changes and files
|
|
55
|
+
changes <uri> the files a session touched [--json]
|
|
56
|
+
[--list] [--scope s] [--<variable> v]
|
|
57
|
+
[--reviewed f] [--unreviewed f]
|
|
58
|
+
[--operations] what may be done to it
|
|
59
|
+
[--run id] [--file f] [--yes] do one of them
|
|
60
|
+
[--list] every changeset it offers
|
|
61
|
+
[--scope <name>] one of them, e.g. turn
|
|
62
|
+
[--turnId <id>] what a chosen scope still needs
|
|
63
|
+
[--reviewed <file>] tick one off, repeatable
|
|
64
|
+
[--unreviewed <file>] and clear one
|
|
65
|
+
content <uri> <file> one of them, in full
|
|
66
|
+
resource list <uri> a directory the host serves [--json]
|
|
67
|
+
resource read <uri> a file on the host
|
|
68
|
+
resource stat <uri> what it is, without reading it [--json]
|
|
69
|
+
resource write <uri> [file] from a file, or from stdin [--create-only]
|
|
70
|
+
guarded by the file's etag unless [--force]
|
|
71
|
+
resource rm <uri> delete it [--recursive]
|
|
72
|
+
resource mkdir <uri> make a directory
|
|
73
|
+
resource mv <uri> <to> move it [--fail-if-exists]
|
|
74
|
+
resource cp <uri> <to> copy it [--fail-if-exists]
|
|
75
|
+
|
|
76
|
+
Automations
|
|
77
|
+
automation list what runs on its own [--json]
|
|
78
|
+
automation show <uri> one of them [--json]
|
|
79
|
+
automation triggers what this host can trigger on [--json]
|
|
80
|
+
automation runs <uri> its history, every page [--json]
|
|
81
|
+
automation run <uri> start it now
|
|
82
|
+
automation enable <uri> switch it on
|
|
83
|
+
automation disable <uri> switch it off
|
|
84
|
+
automation rm <uri> forget it
|
|
85
|
+
|
|
86
|
+
The host's own log
|
|
87
|
+
logs what the daemon is saying [--level L] [--follow]
|
|
88
|
+
|
|
89
|
+
Signing in
|
|
90
|
+
auth what this host protects [--json]
|
|
91
|
+
auth <resource> push a token [--token T] [--expires-in S]
|
|
92
|
+
or set AHPC_TOKEN_<RESOURCE>, or pipe one in
|
|
93
|
+
|
|
94
|
+
Terminals
|
|
95
|
+
terminal list what is running [--json]
|
|
96
|
+
terminal new open a shell [--cwd DIR] [--name N]
|
|
97
|
+
terminal rm <uri> kill it
|
|
98
|
+
terminal send <uri> <text> type into it
|
|
99
|
+
terminal watch <uri> follow its output [--timeout S]
|
|
100
|
+
|
|
101
|
+
Recording
|
|
102
|
+
AHPC_RECORD=<file> append every frame, both directions, for
|
|
103
|
+
'npm run wire' to check against the protocol
|
|
104
|
+
|
|
105
|
+
Anything else
|
|
106
|
+
dispatch <uri> <type> send one action verbatim [--field k=v]… [--chat]
|
|
107
|
+
status what this client is connected to [--json]
|
|
108
|
+
help this
|
|
109
|
+
|
|
110
|
+
The host
|
|
111
|
+
--host <url> ws://host:port, or AHPC_HOST, or the config file
|
|
112
|
+
--token <tkn> a bearer token for it, or AHPC_TOKEN, or the config file
|
|
113
|
+
--config-file read this instead of the one below
|
|
114
|
+
(none) the scripted host, which needs nothing installed
|
|
115
|
+
|
|
116
|
+
Configuration
|
|
117
|
+
config where the file is, and what is in force [--json]
|
|
118
|
+
|
|
119
|
+
Output is for reading. --json is the same answer for a program.
|
|
120
|
+
`;
|
|
121
|
+
/**
|
|
122
|
+
* Flags, read the way agora reads them.
|
|
123
|
+
*
|
|
124
|
+
* A scan rather than a parser: every flag here is `--name value` or a bare
|
|
125
|
+
* switch, and a dependency that handles more shapes than the CLI has would be
|
|
126
|
+
* a dependency for its own sake.
|
|
127
|
+
*/
|
|
128
|
+
class Args {
|
|
129
|
+
rest;
|
|
130
|
+
constructor(rest) {
|
|
131
|
+
this.rest = rest;
|
|
132
|
+
}
|
|
133
|
+
/** The nth thing that is not a flag or a flag's value. */
|
|
134
|
+
positional(index) {
|
|
135
|
+
const found = [];
|
|
136
|
+
for (let i = 0; i < this.rest.length; i++) {
|
|
137
|
+
const word = this.rest[i];
|
|
138
|
+
if (word.startsWith('--')) {
|
|
139
|
+
if (!SWITCHES.has(word))
|
|
140
|
+
i++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
found.push(word);
|
|
144
|
+
}
|
|
145
|
+
return found[index];
|
|
146
|
+
}
|
|
147
|
+
value(flag) {
|
|
148
|
+
const at = this.rest.indexOf(flag);
|
|
149
|
+
return at >= 0 ? this.rest[at + 1] : undefined;
|
|
150
|
+
}
|
|
151
|
+
/** Every `--flag value` of one name, for the flags that repeat. */
|
|
152
|
+
every(flag) {
|
|
153
|
+
const found = [];
|
|
154
|
+
this.rest.forEach((word, index) => {
|
|
155
|
+
if (word === flag && this.rest[index + 1] !== undefined)
|
|
156
|
+
found.push(this.rest[index + 1]);
|
|
157
|
+
});
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
has(flag) { return this.rest.includes(flag); }
|
|
161
|
+
/** `--set k=v --set k2=v2`, as the record the host wants. */
|
|
162
|
+
pairs(flag) {
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const pair of this.every(flag)) {
|
|
165
|
+
const at = pair.indexOf('=');
|
|
166
|
+
if (at <= 0)
|
|
167
|
+
throw new Fault(`${flag} wants key=value, not ${pair}`);
|
|
168
|
+
out[pair.slice(0, at)] = pair.slice(at + 1);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** A message for the person, not a stack trace. */
|
|
174
|
+
export class Fault extends Error {
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Where the host is: a flag, then the environment, then the config file.
|
|
178
|
+
*
|
|
179
|
+
* In that order because each is more deliberate than the next. A flag is this
|
|
180
|
+
* invocation, an environment variable is this shell, and a file is every
|
|
181
|
+
* invocation until somebody edits it - so the narrower answer wins.
|
|
182
|
+
*/
|
|
183
|
+
const where = (args) => {
|
|
184
|
+
const file = loadConfig('ahpc', args.value('--config-file'));
|
|
185
|
+
const host = args.value('--host') ?? process.env.AHPC_HOST ?? file.host;
|
|
186
|
+
const token = args.value('--token') ?? process.env.AHPC_TOKEN ?? file.token;
|
|
187
|
+
return {
|
|
188
|
+
...(host ? { host } : {}),
|
|
189
|
+
...(token ? { token } : {}),
|
|
190
|
+
...(args.value('--cwd') ? { path: args.value('--cwd') } : {}),
|
|
191
|
+
// What this client serves back to the host, and whether the host may
|
|
192
|
+
// write into it. Both off unless asked for.
|
|
193
|
+
...(args.value('--publish') ? { publish: args.value('--publish') } : {}),
|
|
194
|
+
...(args.has('--publish-writable') ? { publishWritable: true } : {}),
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
/** A URI the command needs, said plainly when it is missing. */
|
|
198
|
+
/**
|
|
199
|
+
* The model a command was told to run on, and what it was told to run it at.
|
|
200
|
+
*
|
|
201
|
+
* `--model sonnet --model-config thinkingLevel=high`, repeatable. The second
|
|
202
|
+
* is `ModelSelection.config`, which is where the protocol says the answers to
|
|
203
|
+
* a model's own `configSchema` go - so a level chosen on the command line
|
|
204
|
+
* reaches the host the same way one chosen in the screen does.
|
|
205
|
+
*/
|
|
206
|
+
const selected = (args) => {
|
|
207
|
+
const id = args.value('--model');
|
|
208
|
+
if (id === undefined)
|
|
209
|
+
return undefined;
|
|
210
|
+
const config = {};
|
|
211
|
+
for (const pair of args.every('--model-config')) {
|
|
212
|
+
const at = pair.indexOf('=');
|
|
213
|
+
if (at > 0)
|
|
214
|
+
config[pair.slice(0, at)] = pair.slice(at + 1);
|
|
215
|
+
}
|
|
216
|
+
return { id, ...(Object.keys(config).length > 0 ? { config } : {}) };
|
|
217
|
+
};
|
|
218
|
+
const needs = (args, index, what) => {
|
|
219
|
+
const found = args.positional(index);
|
|
220
|
+
if (!found)
|
|
221
|
+
throw new Fault(`This wants ${what}.`);
|
|
222
|
+
return found;
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Watch one session until it does something, then stop watching.
|
|
226
|
+
*
|
|
227
|
+
* Every streaming command is this with a different stopping condition, so it
|
|
228
|
+
* is written once. The subscription is always closed - a CLI that left one
|
|
229
|
+
* open would be a process that never exits, which is the one thing a shell
|
|
230
|
+
* cannot work around.
|
|
231
|
+
*/
|
|
232
|
+
function until(host, uri, done, options = {}) {
|
|
233
|
+
return new Promise((answer) => {
|
|
234
|
+
let closed = false;
|
|
235
|
+
/*
|
|
236
|
+
* The handle may not exist yet when this runs.
|
|
237
|
+
*
|
|
238
|
+
* A host is entitled to deliver the opening snapshot *synchronously*
|
|
239
|
+
* inside `subscribe` - the scripted one does, and it is the honest thing
|
|
240
|
+
* for a host holding the state already - so a condition satisfied by that
|
|
241
|
+
* first event fires before `subscribe` has returned anything to close.
|
|
242
|
+
* Reading the handle there threw, which made every waiting command fail
|
|
243
|
+
* against the scripted host and work against a socket, purely because one
|
|
244
|
+
* of them answers a tick later.
|
|
245
|
+
*/
|
|
246
|
+
let handle;
|
|
247
|
+
const finish = (event) => {
|
|
248
|
+
if (closed)
|
|
249
|
+
return;
|
|
250
|
+
closed = true;
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
handle?.close();
|
|
253
|
+
answer(event);
|
|
254
|
+
};
|
|
255
|
+
const timer = setTimeout(() => finish(undefined), Math.max(1, (options.timeoutSeconds ?? 900)) * 1000);
|
|
256
|
+
timer.unref?.();
|
|
257
|
+
handle = host.subscribe(uri, (event) => {
|
|
258
|
+
options.onEvent?.(event);
|
|
259
|
+
if (done(event))
|
|
260
|
+
finish(event);
|
|
261
|
+
});
|
|
262
|
+
// Already over, before there was a handle to close. Closing it now is what
|
|
263
|
+
// `finish` could not do.
|
|
264
|
+
if (closed)
|
|
265
|
+
handle.close();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Ask again once the catalogue moves, for an answer that starts out empty.
|
|
270
|
+
*
|
|
271
|
+
* A harness enumerates its models once its host has asked one, and a host that
|
|
272
|
+
* has just been started has not finished asking. An empty list is a real
|
|
273
|
+
* answer - a harness nobody has signed into has none - so this waits for the
|
|
274
|
+
* host to say something changed rather than for a fixed time, and gives up
|
|
275
|
+
* quickly enough that the real empty answer is still prompt.
|
|
276
|
+
*/
|
|
277
|
+
async function settled(host, ask, seconds = 8) {
|
|
278
|
+
const first = await ask();
|
|
279
|
+
if (first.length > 0)
|
|
280
|
+
return first;
|
|
281
|
+
return await new Promise((answer) => {
|
|
282
|
+
const stop = (found) => {
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
watching.close();
|
|
285
|
+
answer(found);
|
|
286
|
+
};
|
|
287
|
+
const timer = setTimeout(() => stop([]), seconds * 1000);
|
|
288
|
+
timer.unref?.();
|
|
289
|
+
const watching = host.onSessions(() => {
|
|
290
|
+
void ask().then((found) => { if (found.length > 0)
|
|
291
|
+
stop(found); }).catch(() => { });
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* How many pages of history `--all` will walk.
|
|
297
|
+
*
|
|
298
|
+
* A bound rather than a promise: a conversation somebody has been having for
|
|
299
|
+
* a year is one this would otherwise read to the end of before printing a
|
|
300
|
+
* line, and stopping is better than appearing to hang.
|
|
301
|
+
*/
|
|
302
|
+
const PAGES = 100;
|
|
303
|
+
/** One session's snapshot, and nothing after it. */
|
|
304
|
+
const snapshot = async (host, uri) => {
|
|
305
|
+
const event = await until(host, uri, (e) => e.type === 'snapshot', { timeoutSeconds: 30 });
|
|
306
|
+
return event?.type === 'snapshot' ? event : undefined;
|
|
307
|
+
};
|
|
308
|
+
/** A turn, as a line of prose rather than a tree of parts. */
|
|
309
|
+
const spoken = (turn) => turn.parts
|
|
310
|
+
.map((part) => (part.kind === 'markdown' ? part.content : ''))
|
|
311
|
+
.join('')
|
|
312
|
+
.trim();
|
|
313
|
+
/**
|
|
314
|
+
* One command, and then the process is done.
|
|
315
|
+
*
|
|
316
|
+
* A switch rather than a registry: every branch is a few lines against
|
|
317
|
+
* `HostConnection`, and the shape of the whole surface being readable in one
|
|
318
|
+
* file is worth more than the indirection a registry would buy.
|
|
319
|
+
*/
|
|
320
|
+
export async function cli(command, rest) {
|
|
321
|
+
const args = new Args(rest);
|
|
322
|
+
const wants = args.has('--json');
|
|
323
|
+
if (command === 'help' || args.has('--help')) {
|
|
324
|
+
process.stdout.write(HELP);
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
327
|
+
/*
|
|
328
|
+
* Answered before any connection, because it is not a question about one.
|
|
329
|
+
*
|
|
330
|
+
* `ahpc config` is what you run when the host cannot be reached and you want
|
|
331
|
+
* to know which host it was trying - so needing a host to answer it would
|
|
332
|
+
* make it useless exactly when it is wanted.
|
|
333
|
+
*/
|
|
334
|
+
if (command === 'config') {
|
|
335
|
+
const file = loadConfig('ahpc', args.value('--config-file'));
|
|
336
|
+
const at = args.value('--config-file') ?? configPath('ahpc');
|
|
337
|
+
if (wants) {
|
|
338
|
+
json({ path: at, values: file });
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
line(at);
|
|
342
|
+
const rows = Object.entries(file).map(([key, value]) => [key, String(value)]);
|
|
343
|
+
if (rows.length === 0)
|
|
344
|
+
line(' (nothing set)');
|
|
345
|
+
else
|
|
346
|
+
table(rows);
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
const host = await connect(where(args));
|
|
350
|
+
try {
|
|
351
|
+
switch (command) {
|
|
352
|
+
case 'status': {
|
|
353
|
+
const rows = await host.listSessions().catch(() => []);
|
|
354
|
+
if (wants) {
|
|
355
|
+
json({ id: host.id, url: host.url, state: host.state(), sessions: rows.length });
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
line(`${host.state()} ${host.url || '(scripted host)'}`);
|
|
359
|
+
line(`${rows.length} session(s)`);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case 'session': return await sessions(host, args, wants);
|
|
363
|
+
case 'chat': return await chats(host, args, wants);
|
|
364
|
+
case 'terminal': return await shells(host, args, wants);
|
|
365
|
+
case 'resource': return await files(host, args, wants);
|
|
366
|
+
case 'auth': return await signIn(host, args, wants);
|
|
367
|
+
case 'automation': return await automation(host, args, wants);
|
|
368
|
+
case 'logs': {
|
|
369
|
+
if (!host.watchLogs)
|
|
370
|
+
throw new Fault('This host emits no logs.');
|
|
371
|
+
const follow = args.has('--follow');
|
|
372
|
+
const watching = await host.watchLogs((record) => {
|
|
373
|
+
if (wants) {
|
|
374
|
+
json(record);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
line([
|
|
378
|
+
record.at?.slice(11, 19) ?? '',
|
|
379
|
+
record.severity ?? '',
|
|
380
|
+
record.body,
|
|
381
|
+
].filter(Boolean).join(' '));
|
|
382
|
+
}, { ...(args.value('--level') ? { level: args.value('--level') } : {}) });
|
|
383
|
+
// Without `--follow` this is a tail of whatever arrives in the next
|
|
384
|
+
// moment, which is what a stateless channel can offer: telemetry is
|
|
385
|
+
// live-edge only and is not replayed.
|
|
386
|
+
if (!follow) {
|
|
387
|
+
await new Promise((resolve) => { setTimeout(resolve, 1500); });
|
|
388
|
+
watching.close();
|
|
389
|
+
}
|
|
390
|
+
else
|
|
391
|
+
await new Promise(() => undefined);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
case 'agents': {
|
|
395
|
+
const found = await settled(host, () => host.agents());
|
|
396
|
+
if (wants) {
|
|
397
|
+
json(found);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
table(found.map((a) => [a.provider, a.displayName ?? '', `${a.models.length} model(s)`]));
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
case 'models': {
|
|
404
|
+
// Settled on the models rather than the harnesses: a host advertises a
|
|
405
|
+
// harness at once and its models when it has asked one.
|
|
406
|
+
const found = await settled(host, async () => (await host.agents()).filter((a) => a.models.length > 0));
|
|
407
|
+
if (wants) {
|
|
408
|
+
json(found.flatMap((a) => a.models));
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
// The levels a model takes, where it says. A model that takes one is
|
|
412
|
+
// as worth saying as a model that takes five, and a column that is
|
|
413
|
+
// empty for most rows is what a person scanning for the exception
|
|
414
|
+
// reads.
|
|
415
|
+
table(found.flatMap((a) => a.models.map((m) => [
|
|
416
|
+
m.provider || a.provider,
|
|
417
|
+
m.id,
|
|
418
|
+
m.displayName,
|
|
419
|
+
(m.options ?? []).flatMap((option) => option.values.map((one) => one.value)).join(' '),
|
|
420
|
+
])));
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
case 'commands': {
|
|
424
|
+
const found = await host.harnessCommands();
|
|
425
|
+
if (wants) {
|
|
426
|
+
json(found);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
table(found.map((c) => [`/${c.name}`, c.kind, c.description ?? '']));
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
/*
|
|
433
|
+
* What every harness on this host offers, with no session anywhere.
|
|
434
|
+
*
|
|
435
|
+
* `session customizations` is the same list resolved against one
|
|
436
|
+
* session's directory. This is the unresolved one, off the root channel,
|
|
437
|
+
* and it is the only one answerable before somebody has decided which
|
|
438
|
+
* agent to start - which is when a person picking a skill to open with
|
|
439
|
+
* is asking.
|
|
440
|
+
*
|
|
441
|
+
* Settled on, because a harness is advertised at once and what it offers
|
|
442
|
+
* arrives when its probe answers.
|
|
443
|
+
*/
|
|
444
|
+
case 'customizations': {
|
|
445
|
+
const kind = args.value('--kind');
|
|
446
|
+
const found = await settled(host, async () => (await host.agents())
|
|
447
|
+
.filter((agent) => (agent.customizations ?? []).length > 0));
|
|
448
|
+
const rows = found.flatMap((agent) => (agent.customizations ?? [])
|
|
449
|
+
.filter((one) => kind === undefined || one.kind === kind)
|
|
450
|
+
.map((one) => ({ provider: agent.provider, ...one })));
|
|
451
|
+
if (wants) {
|
|
452
|
+
json(rows);
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
if (rows.length === 0) {
|
|
456
|
+
line('This host advertises no customizations. A harness nobody has signed into offers none.');
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
table(rows.map((one) => [
|
|
460
|
+
one.provider,
|
|
461
|
+
one.kind,
|
|
462
|
+
one.name,
|
|
463
|
+
// The state is the half a list is read for: a server that needs
|
|
464
|
+
// signing into looks exactly like a working one without it.
|
|
465
|
+
one.state ?? (one.enabled ? 'on' : 'off'),
|
|
466
|
+
// The first sentence, clipped. A skill's description is written for
|
|
467
|
+
// a model deciding whether to load it and runs to a paragraph, which
|
|
468
|
+
// in a table is one row pushing the next sixty off the screen.
|
|
469
|
+
// `--json` is where the whole thing is.
|
|
470
|
+
brief(one.description),
|
|
471
|
+
]));
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
case 'completions': {
|
|
475
|
+
const uri = needs(args, 0, 'a session URI');
|
|
476
|
+
const text = needs(args, 1, 'the text being typed');
|
|
477
|
+
const offset = args.value('--offset');
|
|
478
|
+
const found = await host.completions({
|
|
479
|
+
channel: uri, text, ...(offset ? { offset: Number(offset) } : {}),
|
|
480
|
+
});
|
|
481
|
+
if (wants) {
|
|
482
|
+
json(found);
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
table(found.map((c) => [c.insertText ?? '', c.label ?? '']));
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
case 'changes': {
|
|
489
|
+
const uri = needs(args, 0, 'a session URI');
|
|
490
|
+
const scopes = (await host.changesets?.(uri)) ?? [];
|
|
491
|
+
/** What a scope is called, once the parts still to be filled in are gone. */
|
|
492
|
+
const named = (template) => template
|
|
493
|
+
.replace(uri, '')
|
|
494
|
+
.replace(/^\/changeset\//, '')
|
|
495
|
+
.replace(/\/?\{\w+\}/g, '');
|
|
496
|
+
if (args.has('--list')) {
|
|
497
|
+
if (wants) {
|
|
498
|
+
json(scopes);
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
if (scopes.length === 0) {
|
|
502
|
+
line('This host advertises no changesets.');
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
// The name to pass to --scope first, since that is what this
|
|
506
|
+
// listing is read for.
|
|
507
|
+
table(scopes.map((s) => [
|
|
508
|
+
named(s.uriTemplate),
|
|
509
|
+
s.variables.map((v) => `--${v}`).join(' '),
|
|
510
|
+
s.reviewable ? 'reviewable' : '',
|
|
511
|
+
s.label,
|
|
512
|
+
s.description ?? '',
|
|
513
|
+
]));
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
/*
|
|
517
|
+
* Which one, and what fills it in.
|
|
518
|
+
*
|
|
519
|
+
* A scope is chosen by label or by the tail of its template, because
|
|
520
|
+
* those are what `--list` prints; the variables come from flags named
|
|
521
|
+
* after them, which is the only mapping that survives the protocol
|
|
522
|
+
* adding a template shape this client has never heard of.
|
|
523
|
+
*/
|
|
524
|
+
const wantedName = args.value('--scope');
|
|
525
|
+
const chosen = wantedName === undefined
|
|
526
|
+
? undefined
|
|
527
|
+
: scopes.find((s) => s.label === wantedName
|
|
528
|
+
|| s.uriTemplate === wantedName
|
|
529
|
+
|| named(s.uriTemplate) === wantedName);
|
|
530
|
+
if (wantedName !== undefined && !chosen) {
|
|
531
|
+
throw new Fault(`No changeset called ${wantedName}. 'changes <uri> --list' says what there is.`);
|
|
532
|
+
}
|
|
533
|
+
let target;
|
|
534
|
+
if (chosen) {
|
|
535
|
+
target = chosen.uriTemplate;
|
|
536
|
+
for (const variable of chosen.variables) {
|
|
537
|
+
// `{turnId}` is filled from `--turnId`, and so is anything else
|
|
538
|
+
// the protocol adds later without this needing to know it.
|
|
539
|
+
const given = args.value(`--${variable}`);
|
|
540
|
+
if (given === undefined) {
|
|
541
|
+
throw new Fault(`${chosen.label} needs --${variable}. Its template is ${chosen.uriTemplate}.`);
|
|
542
|
+
}
|
|
543
|
+
target = target.replace(`{${variable}}`, given);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/*
|
|
547
|
+
* Ticking files off, which needs the changeset's own URI.
|
|
548
|
+
*
|
|
549
|
+
* So it is here rather than a command of its own: choosing which
|
|
550
|
+
* changeset is the same question either way, and a second command
|
|
551
|
+
* would have to ask it again.
|
|
552
|
+
*/
|
|
553
|
+
const ticking = args.every('--reviewed').concat(args.every('--unreviewed'));
|
|
554
|
+
if (ticking.length > 0) {
|
|
555
|
+
if (!target)
|
|
556
|
+
throw new Fault('Which changeset? --scope says, and --list says what there is.');
|
|
557
|
+
if (!host.review)
|
|
558
|
+
throw new Fault('This host connection cannot mark files reviewed.');
|
|
559
|
+
const on = args.every('--reviewed');
|
|
560
|
+
const off = args.every('--unreviewed');
|
|
561
|
+
// Whole `file://` URIs are what a row's id is, and what this prints,
|
|
562
|
+
// so a path typed as it was printed is accepted too.
|
|
563
|
+
const idOf = (one) => (one.startsWith('file://') ? one : `file://${one}`);
|
|
564
|
+
if (on.length > 0)
|
|
565
|
+
host.review(target, on.map(idOf), true);
|
|
566
|
+
if (off.length > 0)
|
|
567
|
+
host.review(target, off.map(idOf), false);
|
|
568
|
+
await host.flush?.();
|
|
569
|
+
}
|
|
570
|
+
/*
|
|
571
|
+
* Running one of the verbs the changeset advertises.
|
|
572
|
+
*
|
|
573
|
+
* Here rather than a command of its own for the same reason ticking is:
|
|
574
|
+
* choosing which changeset is the same question, and a second command
|
|
575
|
+
* would ask it again. `--run` names an id from `--operations`, and
|
|
576
|
+
* `--file` points it at a row where the operation is not
|
|
577
|
+
* changeset-wide.
|
|
578
|
+
*/
|
|
579
|
+
const running = args.value('--run');
|
|
580
|
+
if (running !== undefined) {
|
|
581
|
+
if (!target)
|
|
582
|
+
throw new Fault('Which changeset? --scope says, and --list says what there is.');
|
|
583
|
+
if (!host.invoke)
|
|
584
|
+
throw new Fault('This host connection cannot run changeset operations.');
|
|
585
|
+
const set = await host.changes(uri, target);
|
|
586
|
+
const one = (set.operations ?? []).find((op) => op.id === running);
|
|
587
|
+
if (!one) {
|
|
588
|
+
throw new Fault(`No operation called ${running} on that changeset.`
|
|
589
|
+
+ ` It offers ${(set.operations ?? []).map((op) => op.id).join(', ') || 'none'}.`);
|
|
590
|
+
}
|
|
591
|
+
if (one.status === 'disabled')
|
|
592
|
+
throw new Fault(`${one.label} is disabled right now, probably because a turn is running.`);
|
|
593
|
+
const file = args.value('--file');
|
|
594
|
+
const needsFile = !one.scopes.includes('changeset');
|
|
595
|
+
if (needsFile && file === undefined)
|
|
596
|
+
throw new Fault(`${one.label} acts on one file. Pass --file.`);
|
|
597
|
+
// The protocol says a client MUST show the confirmation before
|
|
598
|
+
// invoking. In a shell that means saying it and requiring the person
|
|
599
|
+
// to have meant it.
|
|
600
|
+
if (one.confirmation !== undefined && !args.has('--yes')) {
|
|
601
|
+
throw new Fault(`${one.confirmation}\nPass --yes to go ahead.`);
|
|
602
|
+
}
|
|
603
|
+
const done = await operate(host, target, running, {
|
|
604
|
+
...(needsFile || file !== undefined
|
|
605
|
+
? {
|
|
606
|
+
target: {
|
|
607
|
+
kind: 'resource',
|
|
608
|
+
resource: file?.startsWith('file://') === true ? file : `file://${file ?? ''}`,
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
: {}),
|
|
612
|
+
/*
|
|
613
|
+
* A shell says what it is about to do and does it.
|
|
614
|
+
*
|
|
615
|
+
* `--yes` has already been required for anything the host called
|
|
616
|
+
* destructive, so the person has said so once; making them say it
|
|
617
|
+
* twice for the *permission* would be asking about the plumbing
|
|
618
|
+
* rather than about the act. What is not silent is the fact that
|
|
619
|
+
* access was taken, which is printed.
|
|
620
|
+
*/
|
|
621
|
+
ask: (request) => {
|
|
622
|
+
line(`Asking ${request.uri} for write access.`);
|
|
623
|
+
return true;
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
await host.flush?.();
|
|
627
|
+
if (wants) {
|
|
628
|
+
json(done);
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
line(done.message ?? `${one.label} done.`);
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
const found = await host.changes(uri, target);
|
|
635
|
+
if (wants) {
|
|
636
|
+
json(found);
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
if (args.has('--operations')) {
|
|
640
|
+
const offered = found.operations ?? [];
|
|
641
|
+
if (offered.length === 0) {
|
|
642
|
+
line('This changeset offers nothing to do to it.');
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
// The status is the half worth having: a verb that cannot be pressed
|
|
646
|
+
// right now looks exactly like one that can without it.
|
|
647
|
+
table(offered.map((op) => [
|
|
648
|
+
op.id,
|
|
649
|
+
op.status,
|
|
650
|
+
op.scopes.join('/'),
|
|
651
|
+
op.confirmation !== undefined ? 'asks first' : '',
|
|
652
|
+
op.label,
|
|
653
|
+
brief(op.error?.message ?? op.description),
|
|
654
|
+
]));
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
if (found.files.length === 0) {
|
|
658
|
+
line('No changes.');
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
// Creation and deletion are the absences, which is how the protocol
|
|
662
|
+
// says them: no `before` is new, no `after` is gone.
|
|
663
|
+
table(found.files.map((f) => [
|
|
664
|
+
f.reviewed ? '\u2713' : ' ',
|
|
665
|
+
f.before === undefined ? 'new' : f.after === undefined ? 'gone' : 'edit',
|
|
666
|
+
`+${f.diff.added} -${f.diff.removed}`,
|
|
667
|
+
f.uri.replace(/^file:\/\//, ''),
|
|
668
|
+
]));
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
case 'content': {
|
|
672
|
+
const uri = needs(args, 0, 'a session URI');
|
|
673
|
+
const wanted = needs(args, 1, 'a file in it');
|
|
674
|
+
const set = await host.changes(uri);
|
|
675
|
+
// Matched on the path a person would type, not the whole `file://`
|
|
676
|
+
// URI: the changes listing prints the short form, and what it prints
|
|
677
|
+
// is what can be pasted back in.
|
|
678
|
+
const edit = set.files.find((f) => f.uri === wanted || f.uri.endsWith(`/${wanted}`));
|
|
679
|
+
if (!edit)
|
|
680
|
+
throw new Fault(`No file called ${wanted} in that changeset.`);
|
|
681
|
+
const ref = edit.content?.after ?? edit.content?.before;
|
|
682
|
+
if (!ref)
|
|
683
|
+
throw new Fault('The host kept no content for that file.');
|
|
684
|
+
const found = await host.content(ref);
|
|
685
|
+
if (wants) {
|
|
686
|
+
json(found);
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
line(found.text);
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
case 'prompt':
|
|
693
|
+
case 'exec':
|
|
694
|
+
case 'cancel':
|
|
695
|
+
case 'queue':
|
|
696
|
+
case 'unqueue':
|
|
697
|
+
case 'watch':
|
|
698
|
+
case 'confirm':
|
|
699
|
+
case 'answer':
|
|
700
|
+
case 'dispatch':
|
|
701
|
+
return await turns(host, command, args, wants);
|
|
702
|
+
default:
|
|
703
|
+
process.stderr.write(`No command called ${command}. Try 'ahpc help'.\n`);
|
|
704
|
+
return 2;
|
|
705
|
+
}
|
|
706
|
+
return 0;
|
|
707
|
+
}
|
|
708
|
+
finally {
|
|
709
|
+
// Sent, then hung up. A command that dispatches one action and exits is
|
|
710
|
+
// the only caller that can close a connection faster than its own
|
|
711
|
+
// dispatch leaves it.
|
|
712
|
+
await host.flush?.();
|
|
713
|
+
await host.close?.();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/** Everything under `session`. */
|
|
717
|
+
async function sessions(host, args, wants) {
|
|
718
|
+
const verb = args.positional(0) ?? 'list';
|
|
719
|
+
const uri = args.positional(1);
|
|
720
|
+
if (verb === 'list') {
|
|
721
|
+
const rows = (await host.listSessions())
|
|
722
|
+
.filter((s) => args.has('--archived') || !archived(s.status))
|
|
723
|
+
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
724
|
+
if (wants) {
|
|
725
|
+
json(rows);
|
|
726
|
+
return 0;
|
|
727
|
+
}
|
|
728
|
+
// Empty is a sentence. An empty table is indistinguishable from a table
|
|
729
|
+
// that failed to draw.
|
|
730
|
+
if (rows.length === 0) {
|
|
731
|
+
line('No sessions.');
|
|
732
|
+
return 0;
|
|
733
|
+
}
|
|
734
|
+
table(rows.map((s) => [
|
|
735
|
+
mark(s.status),
|
|
736
|
+
s.title.slice(0, 44),
|
|
737
|
+
[project(s), branch(s)].filter(Boolean).join(' '),
|
|
738
|
+
ago(s.modifiedAt),
|
|
739
|
+
s.resource,
|
|
740
|
+
]));
|
|
741
|
+
return 0;
|
|
742
|
+
}
|
|
743
|
+
if (verb === 'new') {
|
|
744
|
+
const provider = args.value('--agent') ?? (await host.agents())[0]?.provider;
|
|
745
|
+
if (!provider)
|
|
746
|
+
throw new Fault('This host advertises no harness to start one on.');
|
|
747
|
+
const made = await host.createSession({
|
|
748
|
+
provider,
|
|
749
|
+
...(args.value('--cwd') ? { workingDirectory: args.value('--cwd') } : {}),
|
|
750
|
+
...(Object.keys(args.pairs('--set')).length ? { config: args.pairs('--set') } : {}),
|
|
751
|
+
});
|
|
752
|
+
if (wants)
|
|
753
|
+
json({ resource: made });
|
|
754
|
+
else
|
|
755
|
+
line(made);
|
|
756
|
+
return 0;
|
|
757
|
+
}
|
|
758
|
+
if (!uri)
|
|
759
|
+
throw new Fault(`'session ${verb}' wants a session URI.`);
|
|
760
|
+
switch (verb) {
|
|
761
|
+
case 'show': {
|
|
762
|
+
const detail = await host.detail(uri);
|
|
763
|
+
if (args.has('--full') || wants) {
|
|
764
|
+
json(detail);
|
|
765
|
+
return 0;
|
|
766
|
+
}
|
|
767
|
+
const row = (await host.listSessions()).find((s) => s.resource === uri);
|
|
768
|
+
table([
|
|
769
|
+
['Session', uri],
|
|
770
|
+
['Title', row?.title ?? ''],
|
|
771
|
+
['Status', row ? mark(row.status) : ''],
|
|
772
|
+
['Project', row ? [project(row), branch(row)].filter(Boolean).join(' ') : ''],
|
|
773
|
+
['Workspace', (row?.workingDirectories ?? []).map((d) => d.replace(/^file:\/\//, '')).join(', ')],
|
|
774
|
+
['Chat', detail.chat ?? ''],
|
|
775
|
+
['Model', detail.model ? [detail.model.displayName, detail.model.id].filter(Boolean).join(' ') : ''],
|
|
776
|
+
['Updated', row ? ago(row.modifiedAt) : ''],
|
|
777
|
+
].filter(([, value]) => value !== ''));
|
|
778
|
+
return 0;
|
|
779
|
+
}
|
|
780
|
+
case 'rm':
|
|
781
|
+
await host.disposeSession(uri);
|
|
782
|
+
line(`Disposed ${uri}.`);
|
|
783
|
+
return 0;
|
|
784
|
+
case 'read':
|
|
785
|
+
host.setRead(uri, !args.has('--unread'));
|
|
786
|
+
return 0;
|
|
787
|
+
case 'archive':
|
|
788
|
+
host.setArchived(uri, !args.has('--undo'));
|
|
789
|
+
return 0;
|
|
790
|
+
case 'config': {
|
|
791
|
+
const config = await host.config(uri);
|
|
792
|
+
if (wants) {
|
|
793
|
+
json(config);
|
|
794
|
+
return 0;
|
|
795
|
+
}
|
|
796
|
+
table(Object.entries(config.values).map(([key, value]) => [key, String(value)]));
|
|
797
|
+
return 0;
|
|
798
|
+
}
|
|
799
|
+
case 'set': {
|
|
800
|
+
const key = needs(args, 2, 'a config key');
|
|
801
|
+
const value = needs(args, 3, 'a value for it');
|
|
802
|
+
host.setConfig(uri, key, value);
|
|
803
|
+
return 0;
|
|
804
|
+
}
|
|
805
|
+
case 'history': {
|
|
806
|
+
/*
|
|
807
|
+
* Everything the host will give, when asked for it.
|
|
808
|
+
*
|
|
809
|
+
* A snapshot is a tail window on one host and nothing at all on
|
|
810
|
+
* another, so without this the command prints whatever happened to
|
|
811
|
+
* arrive - which against a host that sends no turns is an empty list
|
|
812
|
+
* and no sign that a conversation is there. Bounded, because `--all` is
|
|
813
|
+
* a person asking for a long read and not for an unbounded one.
|
|
814
|
+
*/
|
|
815
|
+
if (args.has('--all')) {
|
|
816
|
+
for (let page = 0; page < PAGES; page += 1) {
|
|
817
|
+
if (!await host.loadOlderTurns(uri))
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const shot = await snapshot(host, uri);
|
|
822
|
+
if (!shot)
|
|
823
|
+
throw new Fault('The host sent no snapshot for that session.');
|
|
824
|
+
const all = [...shot.turns, ...(shot.active ? [shot.active] : [])];
|
|
825
|
+
if (wants || args.has('--full')) {
|
|
826
|
+
json(all);
|
|
827
|
+
return 0;
|
|
828
|
+
}
|
|
829
|
+
for (const turn of all) {
|
|
830
|
+
line(`${turn.role === 'user' ? '›' : '‹'} ${turn.role} ${ago(turn.at)} ${turn.state}`);
|
|
831
|
+
const text = turn.role === 'user' ? (turn.message ?? '') : spoken(turn);
|
|
832
|
+
if (text)
|
|
833
|
+
line(` ${text.replace(/\n/g, '\n ')}`);
|
|
834
|
+
line();
|
|
835
|
+
}
|
|
836
|
+
return 0;
|
|
837
|
+
}
|
|
838
|
+
case 'customizations': {
|
|
839
|
+
const found = await host.customizations(uri);
|
|
840
|
+
if (wants) {
|
|
841
|
+
json(found);
|
|
842
|
+
return 0;
|
|
843
|
+
}
|
|
844
|
+
table(found.map((c) => [c.enabled ? 'on' : 'off', c.kind, c.name, c.description ?? '']));
|
|
845
|
+
return 0;
|
|
846
|
+
}
|
|
847
|
+
/*
|
|
848
|
+
* The whole session, as one document.
|
|
849
|
+
*
|
|
850
|
+
* Everything here could already be read one command at a time and never
|
|
851
|
+
* together, so there was no way to hand somebody a session, keep one after
|
|
852
|
+
* a host is gone, or diff two. This is assembly rather than anything new -
|
|
853
|
+
* `show`, `history`, `customizations` and `changes`, fetched in parallel
|
|
854
|
+
* and written out once.
|
|
855
|
+
*
|
|
856
|
+
* There is no import, and that is not an omission. Nothing in the protocol
|
|
857
|
+
* carries a turn *into* a host: `createSession` starts an empty one and
|
|
858
|
+
* every turn after it is the agent's own work. So a session read out of a
|
|
859
|
+
* host cannot be put back into another, and a command that pretended
|
|
860
|
+
* otherwise would be the worst thing here.
|
|
861
|
+
*/
|
|
862
|
+
case 'export': {
|
|
863
|
+
const [detail, shot, custom, rows] = await Promise.all([
|
|
864
|
+
host.detail(uri),
|
|
865
|
+
snapshot(host, uri),
|
|
866
|
+
host.customizations(uri).catch(() => []),
|
|
867
|
+
host.listSessions().catch(() => []),
|
|
868
|
+
]);
|
|
869
|
+
const row = rows.find((one) => one.resource === uri);
|
|
870
|
+
const turns = [...(shot?.turns ?? []), ...(shot?.active ? [shot.active] : [])];
|
|
871
|
+
/*
|
|
872
|
+
* Every changeset the host will answer for, not only the default one.
|
|
873
|
+
*
|
|
874
|
+
* A scope still carrying `{turnId}` is skipped rather than guessed at:
|
|
875
|
+
* an export that filled a template with the first turn id it saw would
|
|
876
|
+
* be putting a diff in the document that nobody asked about.
|
|
877
|
+
*/
|
|
878
|
+
const scopes = (await host.changesets?.(uri).catch(() => [])) ?? [];
|
|
879
|
+
const sets = await Promise.all(scopes
|
|
880
|
+
.filter((scope) => scope.variables.length === 0)
|
|
881
|
+
.map(async (scope) => ({
|
|
882
|
+
label: scope.label,
|
|
883
|
+
uri: scope.uriTemplate,
|
|
884
|
+
changes: await host.changes(uri, scope.uriTemplate).catch(() => undefined),
|
|
885
|
+
})));
|
|
886
|
+
const document = {
|
|
887
|
+
exportedAt: new Date().toISOString(),
|
|
888
|
+
host: { url: host.url },
|
|
889
|
+
session: { resource: uri, ...(row ?? {}), detail },
|
|
890
|
+
turns,
|
|
891
|
+
customizations: custom,
|
|
892
|
+
changesets: sets.filter((one) => one.changes !== undefined),
|
|
893
|
+
};
|
|
894
|
+
if (!args.has('--markdown')) {
|
|
895
|
+
json(document);
|
|
896
|
+
return 0;
|
|
897
|
+
}
|
|
898
|
+
// The readable form, which is what somebody actually pastes into a
|
|
899
|
+
// ticket. One heading per turn, and the diffs as counts rather than
|
|
900
|
+
// bodies - a changeset of forty files would otherwise bury the
|
|
901
|
+
// conversation the document is about.
|
|
902
|
+
line(`# ${row?.title ?? uri}`);
|
|
903
|
+
line();
|
|
904
|
+
line(`- Session: \`${uri}\``);
|
|
905
|
+
if (row?.provider)
|
|
906
|
+
line(`- Harness: ${row.provider}`);
|
|
907
|
+
if (detail.model)
|
|
908
|
+
line(`- Model: ${detail.model.displayName} (\`${detail.model.id}\`)`);
|
|
909
|
+
if (row?.workingDirectories?.length) {
|
|
910
|
+
line(`- Workspace: ${row.workingDirectories.map((d) => d.replace(/^file:\/\//, '')).join(', ')}`);
|
|
911
|
+
}
|
|
912
|
+
line(`- Exported: ${document.exportedAt}`);
|
|
913
|
+
line();
|
|
914
|
+
for (const turn of turns) {
|
|
915
|
+
const text = turn.role === 'user' ? (turn.message ?? '') : spoken(turn);
|
|
916
|
+
// The model and what it was asked for. An export that named the model
|
|
917
|
+
// and not the thinking level recorded half of what produced the answer
|
|
918
|
+
// underneath it.
|
|
919
|
+
const asked = turn.model
|
|
920
|
+
? [turn.model.id, ...Object.values(turn.model.config ?? {})].join(', ')
|
|
921
|
+
: '';
|
|
922
|
+
line(`## ${turn.role === 'user' ? 'Said' : 'Answered'}${asked ? ` (${asked})` : ''}`);
|
|
923
|
+
line();
|
|
924
|
+
if (text) {
|
|
925
|
+
line(text);
|
|
926
|
+
line();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
for (const set of sets) {
|
|
930
|
+
if (!set.changes || set.changes.files.length === 0)
|
|
931
|
+
continue;
|
|
932
|
+
line(`## ${set.label}`);
|
|
933
|
+
line();
|
|
934
|
+
for (const file of set.changes.files) {
|
|
935
|
+
const kind = file.before === undefined ? 'new' : file.after === undefined ? 'gone' : 'edit';
|
|
936
|
+
line(`- \`${file.uri.replace(/^file:\/\//, '')}\` — ${kind}, +${file.diff.added} -${file.diff.removed}`);
|
|
937
|
+
}
|
|
938
|
+
line();
|
|
939
|
+
}
|
|
940
|
+
return 0;
|
|
941
|
+
}
|
|
942
|
+
case 'toggle': {
|
|
943
|
+
const id = needs(args, 2, 'a customization id');
|
|
944
|
+
host.setCustomizationEnabled(uri, id, !args.has('--off'));
|
|
945
|
+
return 0;
|
|
946
|
+
}
|
|
947
|
+
default: throw new Fault(`No 'session ${verb}'. Try 'ahpc help'.`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* One line of a description, short enough to sit in a column.
|
|
952
|
+
*
|
|
953
|
+
* Skill descriptions are written for a model choosing whether to load one, so
|
|
954
|
+
* they run to a paragraph and carry newlines. `--json` is the whole answer;
|
|
955
|
+
* this is the one a person reads down.
|
|
956
|
+
*/
|
|
957
|
+
const brief = (text, width = 72) => {
|
|
958
|
+
if (!text)
|
|
959
|
+
return '';
|
|
960
|
+
const line_ = text.split('\n')[0]?.trim() ?? '';
|
|
961
|
+
return line_.length > width ? `${line_.slice(0, width - 1)}…` : line_;
|
|
962
|
+
};
|
|
963
|
+
/** Everything under `chat`. */
|
|
964
|
+
async function chats(host, args, wants) {
|
|
965
|
+
const verb = args.positional(0) ?? 'list';
|
|
966
|
+
const uri = args.positional(1);
|
|
967
|
+
if (!uri)
|
|
968
|
+
throw new Fault(`'chat ${verb}' wants a URI.`);
|
|
969
|
+
if (verb === 'list') {
|
|
970
|
+
const event = await until(host, uri, (e) => e.type === 'chats', { timeoutSeconds: 30 });
|
|
971
|
+
const items = event?.type === 'chats' ? event.items : [];
|
|
972
|
+
if (wants) {
|
|
973
|
+
json(items);
|
|
974
|
+
return 0;
|
|
975
|
+
}
|
|
976
|
+
if (items.length === 0) {
|
|
977
|
+
line('One chat, and the host says nothing more about it.');
|
|
978
|
+
return 0;
|
|
979
|
+
}
|
|
980
|
+
table(items.map((c) => [c.resource, c.title]));
|
|
981
|
+
return 0;
|
|
982
|
+
}
|
|
983
|
+
if (verb === 'new') {
|
|
984
|
+
const made = await host.createChat(uri, args.positional(2));
|
|
985
|
+
if (wants)
|
|
986
|
+
json({ resource: made });
|
|
987
|
+
else
|
|
988
|
+
line(made);
|
|
989
|
+
return 0;
|
|
990
|
+
}
|
|
991
|
+
if (verb === 'rm') {
|
|
992
|
+
await host.disposeChat(uri);
|
|
993
|
+
line(`Closed ${uri}.`);
|
|
994
|
+
return 0;
|
|
995
|
+
}
|
|
996
|
+
throw new Fault(`No 'chat ${verb}'. Try 'ahpc help'.`);
|
|
997
|
+
}
|
|
998
|
+
/** Everything under `terminal`. */
|
|
999
|
+
async function shells(host, args, wants) {
|
|
1000
|
+
const verb = args.positional(0) ?? 'list';
|
|
1001
|
+
if (verb === 'list') {
|
|
1002
|
+
const rows = await host.terminals();
|
|
1003
|
+
if (wants) {
|
|
1004
|
+
json(rows);
|
|
1005
|
+
return 0;
|
|
1006
|
+
}
|
|
1007
|
+
if (rows.length === 0) {
|
|
1008
|
+
line('No terminals.');
|
|
1009
|
+
return 0;
|
|
1010
|
+
}
|
|
1011
|
+
table(rows.map((r) => [r.resource, r.title ?? '']));
|
|
1012
|
+
return 0;
|
|
1013
|
+
}
|
|
1014
|
+
if (verb === 'new') {
|
|
1015
|
+
const made = await host.createTerminal({
|
|
1016
|
+
...(args.value('--cwd') ? { cwd: args.value('--cwd') } : {}),
|
|
1017
|
+
...(args.value('--name') ? { name: args.value('--name') } : {}),
|
|
1018
|
+
});
|
|
1019
|
+
if (wants)
|
|
1020
|
+
json({ resource: made });
|
|
1021
|
+
else
|
|
1022
|
+
line(made);
|
|
1023
|
+
return 0;
|
|
1024
|
+
}
|
|
1025
|
+
const uri = args.positional(1);
|
|
1026
|
+
if (!uri)
|
|
1027
|
+
throw new Fault(`'terminal ${verb}' wants a terminal URI.`);
|
|
1028
|
+
if (verb === 'rm') {
|
|
1029
|
+
await host.disposeTerminal(uri);
|
|
1030
|
+
line(`Closed ${uri}.`);
|
|
1031
|
+
return 0;
|
|
1032
|
+
}
|
|
1033
|
+
if (verb === 'send') {
|
|
1034
|
+
// A newline, because a shell over pipes runs a line rather than a string,
|
|
1035
|
+
// and `terminal send ls` that never runs reads as a terminal that is broken.
|
|
1036
|
+
host.writeTerminal(uri, `${needs(args, 2, 'something to type')}\n`);
|
|
1037
|
+
return 0;
|
|
1038
|
+
}
|
|
1039
|
+
if (verb === 'watch') {
|
|
1040
|
+
let last = '';
|
|
1041
|
+
await new Promise((done) => {
|
|
1042
|
+
const timer = setTimeout(() => { handle.close(); done(); }, Math.max(1, Number(args.value('--timeout') ?? 30)) * 1000);
|
|
1043
|
+
timer.unref?.();
|
|
1044
|
+
const handle = host.watchTerminal(uri, (state) => {
|
|
1045
|
+
// Only what is new. The state carries the whole buffer each time, and
|
|
1046
|
+
// re-printing it per event is the same output over and over.
|
|
1047
|
+
const text = state.output ?? '';
|
|
1048
|
+
if (text.startsWith(last))
|
|
1049
|
+
process.stdout.write(text.slice(last.length));
|
|
1050
|
+
else
|
|
1051
|
+
process.stdout.write(text);
|
|
1052
|
+
last = text;
|
|
1053
|
+
if (state.exitCode !== undefined) {
|
|
1054
|
+
clearTimeout(timer);
|
|
1055
|
+
handle.close();
|
|
1056
|
+
done();
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
return 0;
|
|
1061
|
+
}
|
|
1062
|
+
throw new Fault(`No 'terminal ${verb}'. Try 'ahpc help'.`);
|
|
1063
|
+
}
|
|
1064
|
+
/** Everything under `resource`. */
|
|
1065
|
+
async function files(host, args, wants) {
|
|
1066
|
+
const verb = args.positional(0) ?? 'list';
|
|
1067
|
+
const uri = needs(args, 1, 'a file:// URI on the host');
|
|
1068
|
+
if (!host.resourceList || !host.resourceRead) {
|
|
1069
|
+
throw new Fault('This host serves no filesystem. A host is given one, and this one was not.');
|
|
1070
|
+
}
|
|
1071
|
+
if (verb === 'list') {
|
|
1072
|
+
const found = await host.resourceList(uri);
|
|
1073
|
+
if (wants) {
|
|
1074
|
+
json(found);
|
|
1075
|
+
return 0;
|
|
1076
|
+
}
|
|
1077
|
+
if (found.length === 0) {
|
|
1078
|
+
line('Nothing there.');
|
|
1079
|
+
return 0;
|
|
1080
|
+
}
|
|
1081
|
+
// Directories first, then by name: a listing is navigated downwards before
|
|
1082
|
+
// it is read across.
|
|
1083
|
+
const sorted = [...found].sort((a, b) => (a.kind === b.kind
|
|
1084
|
+
? a.name.localeCompare(b.name)
|
|
1085
|
+
: a.kind === 'directory' ? -1 : 1));
|
|
1086
|
+
table(sorted.map((e) => [e.kind === 'directory' ? 'dir' : '', e.name, e.size === undefined ? '' : String(e.size)]));
|
|
1087
|
+
return 0;
|
|
1088
|
+
}
|
|
1089
|
+
if (verb === 'read') {
|
|
1090
|
+
const found = await host.resourceRead(uri);
|
|
1091
|
+
if (wants) {
|
|
1092
|
+
json(found);
|
|
1093
|
+
return 0;
|
|
1094
|
+
}
|
|
1095
|
+
// Bytes as bytes, so `ahpc resource read … > out.png` is a file rather
|
|
1096
|
+
// than a screenful of base64.
|
|
1097
|
+
if (found.encoding === 'base64')
|
|
1098
|
+
process.stdout.write(Buffer.from(found.data, 'base64'));
|
|
1099
|
+
else
|
|
1100
|
+
line(found.data);
|
|
1101
|
+
return 0;
|
|
1102
|
+
}
|
|
1103
|
+
if (verb === 'stat') {
|
|
1104
|
+
if (!host.resourceResolve)
|
|
1105
|
+
throw new Fault('This host does not resolve paths.');
|
|
1106
|
+
const found = await host.resourceResolve(uri);
|
|
1107
|
+
if (wants) {
|
|
1108
|
+
json(found);
|
|
1109
|
+
return 0;
|
|
1110
|
+
}
|
|
1111
|
+
table([
|
|
1112
|
+
['Uri', found.uri],
|
|
1113
|
+
['Type', found.type],
|
|
1114
|
+
...(found.size === undefined ? [] : [['Size', String(found.size)]]),
|
|
1115
|
+
...(found.mtime === undefined ? [] : [['Modified', found.mtime]]),
|
|
1116
|
+
]);
|
|
1117
|
+
return 0;
|
|
1118
|
+
}
|
|
1119
|
+
if (verb === 'write') {
|
|
1120
|
+
if (!host.resourceWrite)
|
|
1121
|
+
throw new Fault('This host serves no writable filesystem.');
|
|
1122
|
+
// From a file, or from stdin: `ahpc resource write <uri> < thing` is how
|
|
1123
|
+
// this gets used, and a second positional is the convenience.
|
|
1124
|
+
const from = args.positional(2);
|
|
1125
|
+
const data = from === undefined ? await readAll(process.stdin) : await readFile(from, 'utf8');
|
|
1126
|
+
/*
|
|
1127
|
+
* The etag the file has now, unless told not to.
|
|
1128
|
+
*
|
|
1129
|
+
* A write with no `ifMatch` lands on whatever is there, which is how a
|
|
1130
|
+
* read-modify-write loses an edit that arrived in between. Resolved here
|
|
1131
|
+
* rather than asked for, because a person writing a file has no way to
|
|
1132
|
+
* know the token and every write from a shell is a read-modify-write.
|
|
1133
|
+
* `--force` is the way to say the current contents do not matter.
|
|
1134
|
+
*/
|
|
1135
|
+
let ifMatch;
|
|
1136
|
+
if (!args.has('--force') && host.resourceResolve) {
|
|
1137
|
+
try {
|
|
1138
|
+
ifMatch = (await host.resourceResolve(uri)).etag;
|
|
1139
|
+
}
|
|
1140
|
+
catch { /* not there yet, so there is nothing to have changed */ }
|
|
1141
|
+
}
|
|
1142
|
+
await host.resourceWrite(uri, data, {
|
|
1143
|
+
...(args.has('--create-only') ? { createOnly: true } : {}),
|
|
1144
|
+
...(ifMatch === undefined ? {} : { ifMatch }),
|
|
1145
|
+
});
|
|
1146
|
+
return 0;
|
|
1147
|
+
}
|
|
1148
|
+
if (verb === 'rm') {
|
|
1149
|
+
if (!host.resourceDelete)
|
|
1150
|
+
throw new Fault('This host serves no writable filesystem.');
|
|
1151
|
+
await host.resourceDelete(uri, { ...(args.has('--recursive') ? { recursive: true } : {}) });
|
|
1152
|
+
return 0;
|
|
1153
|
+
}
|
|
1154
|
+
if (verb === 'mkdir') {
|
|
1155
|
+
if (!host.resourceMkdir)
|
|
1156
|
+
throw new Fault('This host serves no writable filesystem.');
|
|
1157
|
+
await host.resourceMkdir(uri);
|
|
1158
|
+
return 0;
|
|
1159
|
+
}
|
|
1160
|
+
if (verb === 'mv' || verb === 'cp') {
|
|
1161
|
+
const move = host.resourceMove;
|
|
1162
|
+
const copy = host.resourceCopy;
|
|
1163
|
+
if (!move || !copy)
|
|
1164
|
+
throw new Fault('This host serves no writable filesystem.');
|
|
1165
|
+
const to = needs(args, 2, 'somewhere to put it');
|
|
1166
|
+
const options = { ...(args.has('--fail-if-exists') ? { failIfExists: true } : {}) };
|
|
1167
|
+
await (verb === 'mv' ? move(uri, to, options) : copy(uri, to, options));
|
|
1168
|
+
return 0;
|
|
1169
|
+
}
|
|
1170
|
+
throw new Fault(`No 'resource ${verb}'. Try 'ahpc help'.`);
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Automations, from a script.
|
|
1174
|
+
*
|
|
1175
|
+
* The point of an automation is doing something without a person present, so a
|
|
1176
|
+
* feature only a screen can reach is the wrong shape for it. Every verb here
|
|
1177
|
+
* is one the seam already had; `triggers` and `runs` are the two the screen
|
|
1178
|
+
* never asked for either.
|
|
1179
|
+
*/
|
|
1180
|
+
async function automation(host, args, wants) {
|
|
1181
|
+
const verb = args.positional(0) ?? 'list';
|
|
1182
|
+
if (!host.automations)
|
|
1183
|
+
throw new Fault('This host serves no automations.');
|
|
1184
|
+
if (verb === 'list') {
|
|
1185
|
+
const found = await host.automations();
|
|
1186
|
+
if (wants) {
|
|
1187
|
+
json(found);
|
|
1188
|
+
return 0;
|
|
1189
|
+
}
|
|
1190
|
+
table(found.map((one) => [
|
|
1191
|
+
one.enabled ? 'on' : 'off',
|
|
1192
|
+
one.title,
|
|
1193
|
+
one.schedule?.expression ?? '',
|
|
1194
|
+
one.nextRunAt ?? '',
|
|
1195
|
+
one.resource,
|
|
1196
|
+
]));
|
|
1197
|
+
return 0;
|
|
1198
|
+
}
|
|
1199
|
+
if (verb === 'triggers') {
|
|
1200
|
+
if (!host.automationTriggers)
|
|
1201
|
+
throw new Fault('This host does not say which triggers it has.');
|
|
1202
|
+
const found = await host.automationTriggers();
|
|
1203
|
+
if (wants) {
|
|
1204
|
+
json(found);
|
|
1205
|
+
return 0;
|
|
1206
|
+
}
|
|
1207
|
+
table(found.map((one) => [one.kind, one.title ?? '', one.description ?? '']));
|
|
1208
|
+
return 0;
|
|
1209
|
+
}
|
|
1210
|
+
const uri = needs(args, 1, 'an automation URI');
|
|
1211
|
+
if (verb === 'show') {
|
|
1212
|
+
const found = (await host.automations()).find((one) => one.resource === uri);
|
|
1213
|
+
if (!found)
|
|
1214
|
+
throw new Fault(`No automation at ${uri}.`);
|
|
1215
|
+
if (wants) {
|
|
1216
|
+
json(found);
|
|
1217
|
+
return 0;
|
|
1218
|
+
}
|
|
1219
|
+
table([
|
|
1220
|
+
['Title', found.title],
|
|
1221
|
+
['Enabled', found.enabled ? 'yes' : 'no'],
|
|
1222
|
+
['Schedule', found.schedule?.expression ?? ''],
|
|
1223
|
+
['Zone', found.schedule?.timeZone ?? ''],
|
|
1224
|
+
['Next run', found.nextRunAt ?? ''],
|
|
1225
|
+
['Operations', found.operations.join(', ')],
|
|
1226
|
+
].filter(([, value]) => value !== ''));
|
|
1227
|
+
return 0;
|
|
1228
|
+
}
|
|
1229
|
+
if (verb === 'runs') {
|
|
1230
|
+
if (!host.automationRuns)
|
|
1231
|
+
throw new Fault('This host does not page run history.');
|
|
1232
|
+
// Every page, because a cursor is the host's and a caller should not have
|
|
1233
|
+
// to hold one to read a history.
|
|
1234
|
+
const rows = [];
|
|
1235
|
+
let cursor;
|
|
1236
|
+
do {
|
|
1237
|
+
const page = await host.automationRuns(uri, cursor);
|
|
1238
|
+
rows.push(...page.runs);
|
|
1239
|
+
cursor = page.nextCursor;
|
|
1240
|
+
} while (cursor !== undefined && rows.length < 500);
|
|
1241
|
+
if (wants) {
|
|
1242
|
+
json(rows);
|
|
1243
|
+
return 0;
|
|
1244
|
+
}
|
|
1245
|
+
table(rows.map((one) => [one.status, one.startedAt ?? '', one.resource]));
|
|
1246
|
+
return 0;
|
|
1247
|
+
}
|
|
1248
|
+
if (verb === 'run') {
|
|
1249
|
+
if (!host.runAutomation)
|
|
1250
|
+
throw new Fault('This host will not start an automation.');
|
|
1251
|
+
await host.runAutomation(uri);
|
|
1252
|
+
return 0;
|
|
1253
|
+
}
|
|
1254
|
+
if (verb === 'enable' || verb === 'disable') {
|
|
1255
|
+
if (!host.setAutomationEnabled)
|
|
1256
|
+
throw new Fault('This host will not switch an automation.');
|
|
1257
|
+
await host.setAutomationEnabled(uri, verb === 'enable');
|
|
1258
|
+
return 0;
|
|
1259
|
+
}
|
|
1260
|
+
if (verb === 'rm') {
|
|
1261
|
+
if (!host.removeAutomation)
|
|
1262
|
+
throw new Fault('This host will not remove an automation.');
|
|
1263
|
+
// The host revalidates, and asking first is what the specification says a
|
|
1264
|
+
// client SHOULD do: an automation that does not advertise `remove` is one
|
|
1265
|
+
// this refuses rather than one the host refuses.
|
|
1266
|
+
const found = (await host.automations()).find((one) => one.resource === uri);
|
|
1267
|
+
if (found && !found.operations.includes('remove')) {
|
|
1268
|
+
throw new Fault(`${uri} does not offer removal.`);
|
|
1269
|
+
}
|
|
1270
|
+
await host.removeAutomation(uri);
|
|
1271
|
+
return 0;
|
|
1272
|
+
}
|
|
1273
|
+
throw new Fault(`No 'automation ${verb}'. Try list, show, triggers, runs, run, enable, disable, rm.`);
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* A token for one of the host's protected resources.
|
|
1277
|
+
*
|
|
1278
|
+
* `ahpc auth` lists what the host protects; `ahpc auth <resource>` pushes a
|
|
1279
|
+
* token for one. The token comes from `--token`, then an environment variable
|
|
1280
|
+
* named after the resource, then standard input - a flag is this invocation, a
|
|
1281
|
+
* variable is this shell, and neither puts a credential in shell history the
|
|
1282
|
+
* way a positional argument would.
|
|
1283
|
+
*/
|
|
1284
|
+
async function signIn(host, args, wants) {
|
|
1285
|
+
if (!host.protectedResources || !host.authenticate) {
|
|
1286
|
+
throw new Fault('This host serves no protected resources.');
|
|
1287
|
+
}
|
|
1288
|
+
const known = await host.protectedResources();
|
|
1289
|
+
const resource = args.positional(0);
|
|
1290
|
+
if (resource === undefined) {
|
|
1291
|
+
if (wants) {
|
|
1292
|
+
json(known);
|
|
1293
|
+
return 0;
|
|
1294
|
+
}
|
|
1295
|
+
if (known.length === 0) {
|
|
1296
|
+
line('This host protects nothing.');
|
|
1297
|
+
return 0;
|
|
1298
|
+
}
|
|
1299
|
+
table(known.map((one) => [one.resource, one.name ?? '', (one.scopes ?? []).join(' ')]));
|
|
1300
|
+
return 0;
|
|
1301
|
+
}
|
|
1302
|
+
const token = args.value('--token')
|
|
1303
|
+
?? process.env[tokenVariable(resource)]
|
|
1304
|
+
?? (process.stdin.isTTY ? undefined : (await readAll(process.stdin)).trim());
|
|
1305
|
+
if (token === undefined) {
|
|
1306
|
+
throw new Fault(`No token. Pass --token, set ${tokenVariable(resource)}, or pipe one in.`);
|
|
1307
|
+
}
|
|
1308
|
+
// An expiry is only sent when it is known and is a positive integer, which
|
|
1309
|
+
// is what the specification requires of it.
|
|
1310
|
+
const expires = Number(args.value('--expires-in') ?? '');
|
|
1311
|
+
await host.authenticate(resource, token, {
|
|
1312
|
+
...(Number.isInteger(expires) && expires > 0 ? { expiresIn: expires } : {}),
|
|
1313
|
+
});
|
|
1314
|
+
return 0;
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* The environment variable a resource's token is read from.
|
|
1318
|
+
*
|
|
1319
|
+
* Derived from the resource rather than fixed, because a host may protect
|
|
1320
|
+
* several and one variable for all of them is one credential for all of them:
|
|
1321
|
+
* `https://api.anthropic.com` becomes `AHPC_TOKEN_API_ANTHROPIC_COM`.
|
|
1322
|
+
*/
|
|
1323
|
+
function tokenVariable(resource) {
|
|
1324
|
+
const name = resource.replace(/^[a-z]+:\/\//, '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
|
1325
|
+
return `AHPC_TOKEN_${name.toUpperCase()}`;
|
|
1326
|
+
}
|
|
1327
|
+
/** Everything on a stream, for the write that takes its content from a pipe. */
|
|
1328
|
+
async function readAll(stream) {
|
|
1329
|
+
const chunks = [];
|
|
1330
|
+
for await (const chunk of stream)
|
|
1331
|
+
chunks.push(Buffer.from(chunk));
|
|
1332
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
1333
|
+
}
|
|
1334
|
+
/** Prompts, approvals, and the escape hatch. */
|
|
1335
|
+
async function turns(host, command, args, wants) {
|
|
1336
|
+
/**
|
|
1337
|
+
* Stream a turn to stdout and answer with the finished one.
|
|
1338
|
+
*
|
|
1339
|
+
* Written against **snapshots**, because that is what a live host sends. Its
|
|
1340
|
+
* `subscribe` re-emits the whole state on every action rather than turning
|
|
1341
|
+
* each one into a delta - the reducers are the authority on what the state
|
|
1342
|
+
* is now, and a second hand-written path from action to screen would be a
|
|
1343
|
+
* second answer to the same question. So "what is new" is the part of the
|
|
1344
|
+
* running turn not yet printed, which is a length rather than an event.
|
|
1345
|
+
*/
|
|
1346
|
+
const run = async (uri, text) => {
|
|
1347
|
+
let printed = 0;
|
|
1348
|
+
let sawActive = false;
|
|
1349
|
+
let before = new Set();
|
|
1350
|
+
let first = true;
|
|
1351
|
+
let noted;
|
|
1352
|
+
let answer;
|
|
1353
|
+
// Subscribed before saying anything: the first snapshot is the baseline
|
|
1354
|
+
// that says which turns were already there, and one taken afterwards
|
|
1355
|
+
// would count the new turn among them.
|
|
1356
|
+
const finished = until(host, uri, (event) => {
|
|
1357
|
+
if (event.type !== 'snapshot')
|
|
1358
|
+
return false;
|
|
1359
|
+
if (first) {
|
|
1360
|
+
first = false;
|
|
1361
|
+
before = new Set(event.turns.map((turn) => turn.id));
|
|
1362
|
+
}
|
|
1363
|
+
if (event.active) {
|
|
1364
|
+
sawActive = true;
|
|
1365
|
+
if (!wants) {
|
|
1366
|
+
const now = spoken(event.active);
|
|
1367
|
+
if (now.length > printed) {
|
|
1368
|
+
process.stdout.write(now.slice(printed));
|
|
1369
|
+
printed = now.length;
|
|
1370
|
+
}
|
|
1371
|
+
// On stderr, so a pipe still gets only the answer while a person
|
|
1372
|
+
// watching sees why it stopped.
|
|
1373
|
+
const call = event.active.parts.find((part) => part.kind === 'toolCall'
|
|
1374
|
+
&& part.call.status === 'pending-confirmation');
|
|
1375
|
+
if (call?.kind === 'toolCall' && noted !== call.call.id) {
|
|
1376
|
+
noted = call.call.id;
|
|
1377
|
+
process.stderr.write(` · waiting on ${call.call.name} ${call.call.id}\n`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
return false;
|
|
1381
|
+
}
|
|
1382
|
+
// Something wants a person. Not finished, and not this command's to answer.
|
|
1383
|
+
if (event.input)
|
|
1384
|
+
return false;
|
|
1385
|
+
// Nothing running. Done once a turn of *ours* has finished - one that
|
|
1386
|
+
// was not in the baseline, rather than merely the last in the list.
|
|
1387
|
+
const fresh = event.turns.filter((turn) => turn.role === 'agent' && !before.has(turn.id));
|
|
1388
|
+
if (!sawActive && fresh.length === 0)
|
|
1389
|
+
return false;
|
|
1390
|
+
answer = fresh[fresh.length - 1];
|
|
1391
|
+
return true;
|
|
1392
|
+
}, { timeoutSeconds: Number(args.value('--timeout') ?? 900) });
|
|
1393
|
+
host.say(uri, text, selected(args));
|
|
1394
|
+
await finished;
|
|
1395
|
+
if (!wants && printed > 0)
|
|
1396
|
+
line();
|
|
1397
|
+
return answer;
|
|
1398
|
+
};
|
|
1399
|
+
switch (command) {
|
|
1400
|
+
case 'prompt': {
|
|
1401
|
+
const uri = needs(args, 0, 'a session URI');
|
|
1402
|
+
const turn = await run(uri, needs(args, 1, 'something to say'));
|
|
1403
|
+
if (wants)
|
|
1404
|
+
json(turn ?? { state: 'timeout' });
|
|
1405
|
+
return turn?.state === 'complete' ? 0 : 1;
|
|
1406
|
+
}
|
|
1407
|
+
case 'exec': {
|
|
1408
|
+
const text = needs(args, 0, 'something to say');
|
|
1409
|
+
const provider = args.value('--agent') ?? (await host.agents())[0]?.provider;
|
|
1410
|
+
if (!provider)
|
|
1411
|
+
throw new Fault('This host advertises no harness to start one on.');
|
|
1412
|
+
const uri = await host.createSession({
|
|
1413
|
+
provider,
|
|
1414
|
+
...(args.value('--cwd') ? { workingDirectory: args.value('--cwd') } : {}),
|
|
1415
|
+
});
|
|
1416
|
+
try {
|
|
1417
|
+
const turn = await run(uri, text);
|
|
1418
|
+
if (wants)
|
|
1419
|
+
json(turn ?? { state: 'timeout' });
|
|
1420
|
+
return turn?.state === 'complete' ? 0 : 1;
|
|
1421
|
+
}
|
|
1422
|
+
finally {
|
|
1423
|
+
// One turn, and then it is gone - which is what makes this the
|
|
1424
|
+
// one-shot rather than `session new` followed by `prompt`.
|
|
1425
|
+
await host.disposeSession(uri).catch(() => { });
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
case 'cancel':
|
|
1429
|
+
host.stopTurn(needs(args, 0, 'a session URI'));
|
|
1430
|
+
return 0;
|
|
1431
|
+
case 'queue': {
|
|
1432
|
+
const uri = needs(args, 0, 'a session URI');
|
|
1433
|
+
host.queue(uri, needs(args, 1, 'something to say'), selected(args));
|
|
1434
|
+
return 0;
|
|
1435
|
+
}
|
|
1436
|
+
case 'unqueue': {
|
|
1437
|
+
const uri = needs(args, 0, 'a session URI');
|
|
1438
|
+
host.unqueue(uri, needs(args, 1, 'a queued message id'));
|
|
1439
|
+
return 0;
|
|
1440
|
+
}
|
|
1441
|
+
case 'watch': {
|
|
1442
|
+
const uri = needs(args, 0, 'a session URI');
|
|
1443
|
+
const stop = args.value('--until') ?? 'input';
|
|
1444
|
+
let first = true;
|
|
1445
|
+
// What "done" means, so a script can wait for the thing it will act on:
|
|
1446
|
+
// an approval to give, an answer to read, or simply quiet. Read off the
|
|
1447
|
+
// snapshot, because that is the only event a live host sends.
|
|
1448
|
+
const reached = (event) => {
|
|
1449
|
+
if (event.type !== 'snapshot')
|
|
1450
|
+
return false;
|
|
1451
|
+
/*
|
|
1452
|
+
* Whether the state it arrived in counts.
|
|
1453
|
+
*
|
|
1454
|
+
* Two of these are *conditions* and one is an event. A session that is
|
|
1455
|
+
* already quiet satisfies "block until it is quiet", and one that is
|
|
1456
|
+
* already waiting on a person satisfies "block until something wants
|
|
1457
|
+
* a person" - a pending input is not the past, it is still pending,
|
|
1458
|
+
* and skipping it means a script hangs on exactly the approval it was
|
|
1459
|
+
* started to give.
|
|
1460
|
+
*
|
|
1461
|
+
* `turn` is the event: a finished turn in the opening snapshot is
|
|
1462
|
+
* history, and returning it would answer about the turn before this
|
|
1463
|
+
* one.
|
|
1464
|
+
*/
|
|
1465
|
+
const opening = first;
|
|
1466
|
+
first = false;
|
|
1467
|
+
if (stop === 'idle')
|
|
1468
|
+
return event.active === undefined && event.input === undefined;
|
|
1469
|
+
if (stop === 'input')
|
|
1470
|
+
return event.input !== undefined;
|
|
1471
|
+
if (opening)
|
|
1472
|
+
return false;
|
|
1473
|
+
return event.active === undefined && event.turns.length > 0;
|
|
1474
|
+
};
|
|
1475
|
+
const event = await until(host, uri, reached, {
|
|
1476
|
+
timeoutSeconds: Number(args.value('--timeout') ?? 900),
|
|
1477
|
+
});
|
|
1478
|
+
if (!event || event.type !== 'snapshot') {
|
|
1479
|
+
process.stderr.write('Nothing happened before the timeout.\n');
|
|
1480
|
+
return 1;
|
|
1481
|
+
}
|
|
1482
|
+
if (wants) {
|
|
1483
|
+
json(event);
|
|
1484
|
+
return 0;
|
|
1485
|
+
}
|
|
1486
|
+
const input = event.input;
|
|
1487
|
+
if (input?.kind === 'toolConfirmation')
|
|
1488
|
+
line(`tool ${input.call.id} ${input.call.name}`);
|
|
1489
|
+
else if (input?.kind === 'chatInput')
|
|
1490
|
+
line(`question ${input.id} ${input.message}`);
|
|
1491
|
+
else {
|
|
1492
|
+
const last = event.turns[event.turns.length - 1];
|
|
1493
|
+
line(last ? spoken(last) : 'idle');
|
|
1494
|
+
}
|
|
1495
|
+
return 0;
|
|
1496
|
+
}
|
|
1497
|
+
case 'confirm': {
|
|
1498
|
+
const uri = needs(args, 0, 'a session URI');
|
|
1499
|
+
const call = needs(args, 1, 'a tool call id');
|
|
1500
|
+
host.confirmToolCall(uri, call, !args.has('--deny'), args.value('--option'));
|
|
1501
|
+
return 0;
|
|
1502
|
+
}
|
|
1503
|
+
case 'answer': {
|
|
1504
|
+
const uri = needs(args, 0, 'a session URI');
|
|
1505
|
+
const request = needs(args, 1, 'an input request id');
|
|
1506
|
+
// Everything arrives as text, because argv is text. A host that wanted a
|
|
1507
|
+
// number said so in its schema, and guessing here would send `1` for the
|
|
1508
|
+
// answer "1" to a question that asked for a name.
|
|
1509
|
+
const answers = {};
|
|
1510
|
+
for (const [key, value] of Object.entries(args.pairs('--field'))) {
|
|
1511
|
+
answers[key] = { kind: 'text', value };
|
|
1512
|
+
}
|
|
1513
|
+
host.completeInput(uri, request, !args.has('--reject'), answers);
|
|
1514
|
+
return 0;
|
|
1515
|
+
}
|
|
1516
|
+
case 'dispatch': {
|
|
1517
|
+
const uri = needs(args, 0, 'a session URI');
|
|
1518
|
+
const type = needs(args, 1, 'an action type, e.g. session/isReadChanged');
|
|
1519
|
+
if (!host.dispatch) {
|
|
1520
|
+
throw new Fault('This host connection sends no raw actions. Only a live host does.');
|
|
1521
|
+
}
|
|
1522
|
+
const action = { type };
|
|
1523
|
+
for (const [key, value] of Object.entries(args.pairs('--field'))) {
|
|
1524
|
+
// `true`, `false` and numbers as themselves; everything else as text.
|
|
1525
|
+
// An action field is typed by the protocol, and sending "true" where a
|
|
1526
|
+
// boolean belongs is a dispatch the host quietly ignores.
|
|
1527
|
+
action[key] = value === 'true' ? true : value === 'false' ? false
|
|
1528
|
+
: /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value;
|
|
1529
|
+
}
|
|
1530
|
+
host.dispatch(uri, action, args.has('--chat'));
|
|
1531
|
+
if (wants)
|
|
1532
|
+
json(action);
|
|
1533
|
+
return 0;
|
|
1534
|
+
}
|
|
1535
|
+
default: throw new Fault(`No command called ${command}.`);
|
|
1536
|
+
}
|
|
1537
|
+
}
|