@softov/ahpc 0.2.0 → 0.4.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 +37 -6
- package/dist/src/ahp/fake.js +61 -14
- package/dist/src/ahp/live.d.ts +7 -0
- package/dist/src/ahp/live.js +156 -43
- package/dist/src/ahp/publish.js +13 -0
- package/dist/src/ahp/types.d.ts +20 -0
- package/dist/src/app.js +34 -7
- package/dist/src/blocks.d.ts +4 -74
- package/dist/src/blocks.js +10 -48
- package/dist/src/cli/main.d.ts +1 -1
- package/dist/src/cli/main.js +109 -6
- package/dist/src/connect.d.ts +2 -0
- package/dist/src/connect.js +1 -0
- package/dist/src/control.d.ts +6 -0
- package/dist/src/control.js +140 -10
- package/dist/src/flags.js +4 -1
- package/dist/src/links.d.ts +54 -0
- package/dist/src/links.js +120 -0
- package/dist/src/main.js +14 -2
- package/dist/src/mcp/http.d.ts +10 -0
- package/dist/src/mcp/http.js +110 -3
- package/dist/src/mcp/serve.d.ts +47 -9
- package/dist/src/mcp/serve.js +65 -14
- package/dist/src/mcp/stdio.d.ts +1 -0
- package/dist/src/mcp/stdio.js +7 -1
- package/dist/src/mcp/tools.d.ts +37 -2
- package/dist/src/mcp/tools.js +439 -2
- package/dist/src/resources.d.ts +13 -0
- package/dist/src/resources.js +46 -0
- package/dist/src/screens.js +95 -50
- package/dist/src/state.d.ts +42 -0
- package/dist/src/state.js +80 -1
- package/dist/src/tui.d.ts +3 -1
- package/dist/src/tui.js +29 -4
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +38 -0
- package/dist/src/view/creature.d.ts +0 -12
- package/dist/src/view/creature.js +0 -20
- package/dist/src/view/wire.d.ts +36 -0
- package/dist/src/view/wire.js +196 -0
- package/dist/src/wait.d.ts +11 -0
- package/dist/src/wait.js +15 -2
- package/dist/src/wire.d.ts +70 -0
- package/dist/src/wire.js +194 -0
- package/dist/src/wiretui.d.ts +22 -0
- package/dist/src/wiretui.js +69 -0
- package/package.json +6 -5
- package/dist/src/diff.d.ts +0 -44
- package/dist/src/diff.js +0 -111
- package/dist/src/view/bubble.d.ts +0 -75
- package/dist/src/view/bubble.js +0 -86
- package/dist/src/view/composer.d.ts +0 -64
- package/dist/src/view/composer.js +0 -192
- package/dist/src/view/controls.d.ts +0 -44
- package/dist/src/view/controls.js +0 -49
- package/dist/src/view/details.d.ts +0 -65
- package/dist/src/view/details.js +0 -65
- package/dist/src/view/filediff.d.ts +0 -29
- package/dist/src/view/filediff.js +0 -24
- package/dist/src/view/hitl.d.ts +0 -43
- package/dist/src/view/hitl.js +0 -171
- package/dist/src/view/icons.d.ts +0 -13
- package/dist/src/view/icons.js +0 -71
- package/dist/src/view/picker.d.ts +0 -42
- package/dist/src/view/picker.js +0 -71
- package/dist/src/view/sessionhead.d.ts +0 -41
- package/dist/src/view/sessionhead.js +0 -60
- package/dist/src/view/sessions.d.ts +0 -34
- package/dist/src/view/sessions.js +0 -61
- package/dist/src/view/toolcall.d.ts +0 -27
- package/dist/src/view/toolcall.js +0 -48
- package/dist/src/view/transcript.d.ts +0 -50
- package/dist/src/view/transcript.js +0 -60
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# ahpc
|
|
2
2
|
|
|
3
|
+
[](https://github.com/softov/ahpc/actions/workflows/ci.yml)
|
|
3
4
|
[](https://www.npmjs.com/package/@softov/ahpc)
|
|
4
5
|

|
|
5
6
|

|
|
@@ -73,7 +74,7 @@ Inside a session, the header shows the model, thinking level, permission mode, w
|
|
|
73
74
|
|
|
74
75
|

|
|
75
76
|
|
|
76
|
-
Before the first message, a new session asks the agent, the model and its options, the permission mode and the workspace. The questions come from the host's `configSchema`, so options `ahpc` has never seen still get a
|
|
77
|
+
Before the first message, a new session asks the agent, the model and its options, the permission mode and the workspace. The workspace is picked by looking: the chip opens a folder dialog over the host's own directories (`resourceList`, the same request VS Code's folder picker makes), starting where the chip points when the host will list that and in a directory some session is in otherwise; `Workspace path` in the palette still takes one typed, for a served directory the dialog cannot walk to. The questions come from the host's `configSchema`, so options `ahpc` has never seen still get a chip. Two rows under the field: what runs on the first, and where it runs on the second - the directory, in place or in a worktree of it, and the branch a worktree starts from. What the host marks `readOnly` is shown and not asked, and the worktree seeds the reference client never draws (`worktreeBranchPrefix`, `worktreeCreateNewBranch`, `worktreeBranchTrack`, `worktreeIncludeFiles`, `shellInitScripts`) are not drawn here either.
|
|
77
78
|
|
|
78
79
|
### Keys
|
|
79
80
|
|
|
@@ -81,14 +82,15 @@ Before the first message, a new session asks the agent, the model and its option
|
|
|
81
82
|
|---|---|
|
|
82
83
|
| `enter` | Send |
|
|
83
84
|
| `alt+enter` | Newline |
|
|
84
|
-
| `tab` | Move
|
|
85
|
-
| `esc` | Close the menu, then leave the field, then go back |
|
|
85
|
+
| `tab` | Move through the option rows |
|
|
86
|
+
| `esc` | Close the menu, then leave the field, then go back. On an option, back to the field |
|
|
86
87
|
| `/` | Slash commands, from the host and from `ahpc` — `/config` opens the settings palette |
|
|
87
88
|
| `@` | Complete a file path on the host |
|
|
88
89
|
| `ctrl+g` | Edit the message in `$VISUAL` or `$EDITOR` |
|
|
89
90
|
| `ctrl+p` | Command palette |
|
|
90
91
|
| `f1` | Every key that works where you are |
|
|
91
92
|
| `ctrl+f` | Filter the session list, or find in the open conversation |
|
|
93
|
+
| `l` | Follow an `agent-host-session://` link in the open conversation: the links the agent's session tools answered with, as a list, and one chosen opens the session or the chat it names. `--session` and every `<uri>` on the command line take a link too |
|
|
92
94
|
| `ctrl+n` | New session |
|
|
93
95
|
| `ctrl+r` | Refresh |
|
|
94
96
|
| `alt+t` | Theme |
|
|
@@ -214,15 +216,17 @@ Writes are guarded by the file's etag unless you pass `--force`, so two clients
|
|
|
214
216
|
|
|
215
217
|
| Command | | |
|
|
216
218
|
|---|---|---|
|
|
217
|
-
| `mcp` | MCP on stdin and stdout, for a client that launches this process | |
|
|
218
|
-
| `serve` | The same tools on a socket, shared | `--serve-host H` `--serve-port N` `--serve-token T` |
|
|
219
|
+
| `mcp` | MCP on stdin and stdout, for a client that launches this process | `--mcp-tools G,…` |
|
|
220
|
+
| `serve` | The same tools on a socket, shared | `--serve-host H` `--serve-port N` `--serve-token T` `--serve-origin URL` `--mcp-tools G,…` |
|
|
219
221
|
|
|
220
222
|
### Anything else
|
|
221
223
|
|
|
222
224
|
| Command | | |
|
|
223
225
|
|---|---|---|
|
|
224
226
|
| `dispatch <uri> <type>` | Send a raw protocol action | `--field k=v` `--chat` |
|
|
227
|
+
| `wire <file>` | Watch a `--wire` capture as it is written, from either end: one row per frame, a row opens to the frame. Off a terminal, one line per frame | `--follow` `--filter text` `--json` |
|
|
225
228
|
| `config` | Show the config file path and current values | `--json` |
|
|
229
|
+
| `--version` | What version this is | |
|
|
226
230
|
| `help` | Print this command list | |
|
|
227
231
|
|
|
228
232
|
## As a tool server
|
|
@@ -252,10 +256,37 @@ curl -XPOST localhost:7431/api/new_session -d '{"workingDirectory":"/work"}'
|
|
|
252
256
|
curl -XPOST localhost:7431/api/send_turn -d '{"session":"claude:/…","text":"what is in this directory"}'
|
|
253
257
|
```
|
|
254
258
|
|
|
259
|
+
### More than the twelve
|
|
260
|
+
|
|
261
|
+
Files, terminals, automations and changesets are there too, one group at a time, and off unless asked for. That is on purpose: a tool table is read by a model alongside everything else it was given, and thirty tools is a worse server than twelve for the thing almost everybody wants, which is driving a session.
|
|
262
|
+
|
|
263
|
+
```sh
|
|
264
|
+
ahpc --host ws://127.0.0.1:9187 mcp --mcp-tools resources,changes
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
| Group | Tools |
|
|
268
|
+
|---|---|
|
|
269
|
+
| `resources` | `list_directory` `read_file` `write_file` `make_directory` `delete_path` `move_path` `copy_path` |
|
|
270
|
+
| `terminals` | `list_terminals` `new_terminal` `send_to_terminal` `read_terminal` `dispose_terminal` |
|
|
271
|
+
| `automations` | `list_automations` `run_automation` `set_automation_enabled` `remove_automation` |
|
|
272
|
+
| `changes` | `list_changesets` `show_changes` |
|
|
273
|
+
|
|
274
|
+
Repeatable as well as comma-separated. It is `--mcp-tools` rather than `--tools` because every other flag on this client is an AHP thing, and a bare `--tools` would read like it was choosing which tools the *agent* may call - a different question with a different answer.
|
|
275
|
+
|
|
276
|
+
Calling a tool from a group nobody turned on is refused with the flag that would turn it on, rather than with "no such tool", because those are different problems and only the person who started the server can fix the first.
|
|
277
|
+
|
|
278
|
+
Writing needs the host to have granted write access to that directory, and a host that has not refuses with `-32009` saying so. That is not something this client can grant on a model's behalf: it is the same question a person answers before a session may edit their repository.
|
|
279
|
+
|
|
280
|
+
`read_file` and `write_file` take a `file://` URI on the *host*, not a path on the machine running `ahpc` - the two may not be the same machine. `write_file` reads the file's etag first and refuses a write if it changed in between, unless passed `force`; a model reading a file, thinking, and writing it back is a read-modify-write with a person editing in the middle of it.
|
|
281
|
+
|
|
255
282
|
`send_turn` blocks until the turn ends and returns what the agent said. A turn that stops to ask a person something is not finished: `wait_for_attention` says what it wants, and `confirm_tool_call` and `answer_question` answer it.
|
|
256
283
|
|
|
284
|
+
A caller that does not want to sit in silence for a minute puts a `progressToken` in the request's `_meta`, and gets a `notifications/progress` line for each tool the agent reaches for. On `serve` that also decides the shape of the reply: asked for, the POST is answered with an SSE stream carrying the notifications and then the result; not asked for, it is one JSON object. MCP has no shape for streaming partial *result* content, so the reply itself still arrives whole at the end - what this fixes is an agent that looked frozen, not one you want to watch write.
|
|
285
|
+
|
|
257
286
|
It binds to `127.0.0.1` unless told otherwise, because anybody who can reach the port can drive every session on the host. `--serve-token` sets a bearer token, which is what makes `--serve-host 0.0.0.0` defensible.
|
|
258
287
|
|
|
288
|
+
Requests carrying a browser `Origin` are refused unless the origin is this server's own or was named with `--serve-origin`, repeatable. That is the transport's own rule and it is not paranoia: loopback is not the protection it looks like, because a page on any site can POST to `127.0.0.1` from inside the browser of the person running this, and the request arrives from their own machine. A program - a script, a webhook, an MCP client - sends no `Origin` and is let through.
|
|
289
|
+
|
|
259
290
|
## AHP support
|
|
260
291
|
|
|
261
292
|
All 30 client-to-server requests are implemented, and 21 of the 45 client-dispatchable actions are used. `ahpc` subscribes to the root, session, chat, terminal and automation channels, plus the telemetry channel the host advertises for its log.
|
|
@@ -324,7 +355,7 @@ npm run schema # a strict JSON Schema from the package's own declar
|
|
|
324
355
|
npm run wire -- <capture> # check a recording against it
|
|
325
356
|
```
|
|
326
357
|
|
|
327
|
-
`AHPC_RECORD=<file>` appends every frame sent and received. `test/conformance.test.ts` runs the same check against frames produced by the test run itself, so it cannot pass on a stale recording.
|
|
358
|
+
`--wire <file>` (or `AHPC_RECORD=<file>`) appends every frame sent and received as one JSON line each, `{ at, from, peer, frame }`, the lines `ahpd --wire` writes, so a capture from either end reads the same and `jq` reads both. `ahpc wire <file>` watches one as it is written: a row per frame with the time, the direction, the method or action type and the channel, a filter over all of them, and the frame itself beside the list or, on a narrow terminal, under enter. Reading the host's capture and this client's side by side is what it is for. `test/conformance.test.ts` runs the same check against frames produced by the test run itself, so it cannot pass on a stale recording.
|
|
328
359
|
|
|
329
360
|
The screens, widgets and input handling come from [TextUI](https://github.com/softov/textui) — `@textui/core` for components and state, `@textui/widgets` for the catalog, `@textui/terminal` for rendering and key decoding, and `@textui/testing` for the harness the tests run in. `ahpc` began as an example inside it.
|
|
330
361
|
|
package/dist/src/ahp/fake.js
CHANGED
|
@@ -17,7 +17,16 @@ const EFFORTS = [
|
|
|
17
17
|
{ value: 'xhigh', label: 'Extra High' },
|
|
18
18
|
{ value: 'max', label: 'Max' },
|
|
19
19
|
];
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The session's questions, as the reference host asks them.
|
|
22
|
+
*
|
|
23
|
+
* A function of the answers so far rather than a table: `branch` is a
|
|
24
|
+
* question only while a worktree is being made, and the reference host marks
|
|
25
|
+
* it read-only otherwise, because a folder session works on whatever is
|
|
26
|
+
* checked out. `worktreeCreateNewBranch` is one of the values it seeds from
|
|
27
|
+
* the client's own settings and never asks about.
|
|
28
|
+
*/
|
|
29
|
+
const configOf = (values, branch) => [
|
|
21
30
|
{
|
|
22
31
|
key: 'branch',
|
|
23
32
|
title: 'Branch',
|
|
@@ -26,6 +35,19 @@ const CONFIG = [
|
|
|
26
35
|
// reference host marks exactly this property this way.
|
|
27
36
|
values: [],
|
|
28
37
|
enumDynamic: true,
|
|
38
|
+
default: branch,
|
|
39
|
+
...(values.isolation === 'worktree' ? {} : { readOnly: true }),
|
|
40
|
+
sessionMutable: false,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: 'worktreeCreateNewBranch',
|
|
44
|
+
title: 'Create a branch',
|
|
45
|
+
values: [
|
|
46
|
+
{ value: 'true', label: 'Create one' },
|
|
47
|
+
{ value: 'false', label: 'Continue the chosen branch' },
|
|
48
|
+
],
|
|
49
|
+
default: 'true',
|
|
50
|
+
readOnly: true,
|
|
29
51
|
sessionMutable: false,
|
|
30
52
|
},
|
|
31
53
|
{
|
|
@@ -51,6 +73,19 @@ const CONFIG = [
|
|
|
51
73
|
],
|
|
52
74
|
},
|
|
53
75
|
];
|
|
76
|
+
const DEFAULTS = { permissionMode: 'default', isolation: 'workspace' };
|
|
77
|
+
/**
|
|
78
|
+
* The questions, answered so far, about one directory.
|
|
79
|
+
*
|
|
80
|
+
* `branch` is the directory's: a real host starts from the branch that is
|
|
81
|
+
* checked out there, so asking about another directory is asking a different
|
|
82
|
+
* question, and this fixture answers it from what the session in that
|
|
83
|
+
* directory said it was on.
|
|
84
|
+
*/
|
|
85
|
+
const configFor = (values, branch = 'main') => {
|
|
86
|
+
const held = { ...DEFAULTS, branch, ...values };
|
|
87
|
+
return { properties: configOf(held, branch), values: held };
|
|
88
|
+
};
|
|
54
89
|
let counter = 0;
|
|
55
90
|
const nextId = (prefix) => `${prefix}${++counter}`;
|
|
56
91
|
const AT = '2026-08-22T10:00:00.000Z';
|
|
@@ -468,6 +503,19 @@ export function fakeHost() {
|
|
|
468
503
|
outgoingChanges: options.drift?.[0] ?? 0,
|
|
469
504
|
uncommittedChanges: options.drift?.[2] ?? 0,
|
|
470
505
|
},
|
|
506
|
+
// The reference host's other well-known key: what GitHub knows about
|
|
507
|
+
// the branch. Copied from a capture the same way `git` is.
|
|
508
|
+
...(options.pullRequest
|
|
509
|
+
? {
|
|
510
|
+
github: {
|
|
511
|
+
pullRequestUrls: [options.pullRequest.url],
|
|
512
|
+
pullRequestBranchName: options.branch ?? 'main',
|
|
513
|
+
...(options.pullRequest.state
|
|
514
|
+
? { pullRequestState: options.pullRequest.state, pullRequestStateUrl: options.pullRequest.url }
|
|
515
|
+
: {}),
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
: {}),
|
|
471
519
|
},
|
|
472
520
|
...(options.activity ? { activity: options.activity } : {}),
|
|
473
521
|
...(options.origin ? { origin: options.origin } : {}),
|
|
@@ -640,6 +688,8 @@ export function fakeHost() {
|
|
|
640
688
|
dir: 'file:///brb_main/src/brb_backend',
|
|
641
689
|
model: 'claude-sonnet-5',
|
|
642
690
|
archived: true,
|
|
691
|
+
branch: 'cleanup/compile-script',
|
|
692
|
+
pullRequest: { url: 'https://github.com/brbyte/brb_backend/pull/412', state: 'merged' },
|
|
643
693
|
turns: [
|
|
644
694
|
{
|
|
645
695
|
id: 's5-t1', role: 'user', message: 'Delete compileFramework.sh from the Linux path.',
|
|
@@ -1126,11 +1176,14 @@ export function fakeHost() {
|
|
|
1126
1176
|
agents: async () => AGENTS,
|
|
1127
1177
|
// Iterative, as a real host's is: what has been answered comes back
|
|
1128
1178
|
// answered. A fixture that returns its defaults every time quietly undoes
|
|
1129
|
-
// every choice the moment anything asks the question again.
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1179
|
+
// every choice the moment anything asks the question again. About the
|
|
1180
|
+
// directory asked about, as a real host's is: the branch is what is
|
|
1181
|
+
// checked out there.
|
|
1182
|
+
resolveConfig: async ({ workingDirectory, values }) => {
|
|
1183
|
+
const there = [...summaries.values()].find((one) => one.workingDirectories[0] === `file://${workingDirectory ?? ''}`);
|
|
1184
|
+
const meta = there?._meta;
|
|
1185
|
+
return configFor(values ?? {}, meta?.git?.branchName ?? 'main');
|
|
1186
|
+
},
|
|
1134
1187
|
automations: async () => [...automations.values()],
|
|
1135
1188
|
onAutomations: (observer) => {
|
|
1136
1189
|
automationWatchers.add(observer);
|
|
@@ -1961,10 +2014,7 @@ export function fakeHost() {
|
|
|
1961
2014
|
chat,
|
|
1962
2015
|
chats: chatsOf(uri),
|
|
1963
2016
|
lifecycle: summaries.has(uri) ? 'ready' : 'creating',
|
|
1964
|
-
config: {
|
|
1965
|
-
properties: CONFIG,
|
|
1966
|
-
values: { permissionMode: 'default', isolation: 'workspace', ...(configs.get(uri) ?? {}) },
|
|
1967
|
-
},
|
|
2017
|
+
config: configFor(configs.get(uri) ?? {}),
|
|
1968
2018
|
// The id a turn named, resolved against the catalogue - which is what
|
|
1969
2019
|
// the live host does, and a fixture that answered a bare id would be
|
|
1970
2020
|
// one where the screens were never asked to resolve anything.
|
|
@@ -1972,10 +2022,7 @@ export function fakeHost() {
|
|
|
1972
2022
|
...(summaries.get(uri)?.activity ? { activity: summaries.get(uri)?.activity } : {}),
|
|
1973
2023
|
};
|
|
1974
2024
|
},
|
|
1975
|
-
config: async (uri) => ({
|
|
1976
|
-
properties: CONFIG,
|
|
1977
|
-
values: { permissionMode: 'default', isolation: 'workspace', ...(configs.get(uri) ?? {}) },
|
|
1978
|
-
}),
|
|
2025
|
+
config: async (uri) => configFor(configs.get(uri) ?? {}),
|
|
1979
2026
|
setConfig: (uri, key, value) => {
|
|
1980
2027
|
// One key, merged. Writing the whole object back is how a value another
|
|
1981
2028
|
// client changed a moment ago is quietly reverted.
|
package/dist/src/ahp/live.d.ts
CHANGED
|
@@ -33,6 +33,13 @@ export interface LiveHostOptions {
|
|
|
33
33
|
/** A bearer token, if the host is behind one. Appended as `?tkn=`. */
|
|
34
34
|
token?: string;
|
|
35
35
|
clientId?: string;
|
|
36
|
+
/**
|
|
37
|
+
* A file every frame is appended to, both directions, one JSON line each.
|
|
38
|
+
*
|
|
39
|
+
* `{ at, from, peer, frame }`, the lines `ahpd --wire` writes. `AHPC_RECORD`
|
|
40
|
+
* in the environment is the same thing spelt for a shell.
|
|
41
|
+
*/
|
|
42
|
+
wire?: string;
|
|
36
43
|
/** Told when the socket drops, so the badge can stop claiming otherwise. */
|
|
37
44
|
onState?(state: 'connecting' | 'connected' | 'offline'): void;
|
|
38
45
|
/**
|
package/dist/src/ahp/live.js
CHANGED
|
@@ -48,20 +48,23 @@ function isRpcRefusal(error) {
|
|
|
48
48
|
/**
|
|
49
49
|
* Versions to offer at `initialize`, most preferred first.
|
|
50
50
|
*
|
|
51
|
-
* A host picks the
|
|
52
|
-
* than a floor.
|
|
53
|
-
*
|
|
51
|
+
* A host picks the highest entry it also speaks, so this is a preference
|
|
52
|
+
* rather than a floor. `0.9.0` is the newest published and the version the
|
|
53
|
+
* package below is built from; the two behind it are what an older host
|
|
54
|
+
* answers with, and every command used here is stable across all three.
|
|
54
55
|
*
|
|
55
|
-
* `1.0.0`
|
|
56
|
-
* repository and
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
56
|
+
* `1.0.0` was here for two weeks, first. VS Code's host vendors the protocol
|
|
57
|
+
* from its repository and for that long carried a `1.0.0` that never reached
|
|
58
|
+
* the repository's `main`; it accepted `^1.0.0` and refused every `0.x` with
|
|
59
|
+
* `-32005`, so offering it was the only way in. It has since resynced to the
|
|
60
|
+
* published `0.9.0`. Offering a version the installed types do not describe is
|
|
61
|
+
* the wrong kind of forward-compatibility - a host that took it could answer
|
|
62
|
+
* in a shape nothing here has heard of - so it came out the day no host
|
|
63
|
+
* needed it.
|
|
61
64
|
*
|
|
62
65
|
* This list is load-bearing, because there is no fallback behind it.
|
|
63
66
|
*/
|
|
64
|
-
const VERSIONS = ['
|
|
67
|
+
const VERSIONS = ['0.9.0', '0.8.0', '0.7.0'];
|
|
65
68
|
const ROOT = 'ahp-root://';
|
|
66
69
|
const AUTOMATIONS = 'ahp-automations://';
|
|
67
70
|
export class MissingProtocolPackage extends Error {
|
|
@@ -90,22 +93,32 @@ function locale() {
|
|
|
90
93
|
return undefined;
|
|
91
94
|
return tag;
|
|
92
95
|
}
|
|
93
|
-
function tee(inner, heard) {
|
|
96
|
+
function tee(inner, heard, wire) {
|
|
94
97
|
/*
|
|
95
|
-
* Every frame, to a file, when `AHPC_RECORD` names one.
|
|
98
|
+
* Every frame, to a file, when `--wire` or `AHPC_RECORD` names one.
|
|
96
99
|
*
|
|
97
100
|
* Both directions: `tools/validate.mjs` checks what a host sent *and* what
|
|
98
101
|
* this client sent, and until this existed the only captures to check were
|
|
99
|
-
* another client's traffic.
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
+
* another client's traffic. The lines are the ones `ahpd --wire` writes -
|
|
103
|
+
* `at`, `from`, `peer`, `frame` - so one reader serves a capture from either
|
|
104
|
+
* end, and `peer` is the host's URL so a capture across two hosts can be
|
|
105
|
+
* read apart. Appended synchronously and deliberately - a recording that
|
|
106
|
+
* lost the frame a crash happened on would be a recording of everything
|
|
107
|
+
* except the interesting part.
|
|
102
108
|
*/
|
|
103
|
-
const recording = process.env.AHPC_RECORD;
|
|
104
|
-
const write = (from,
|
|
109
|
+
const recording = wire.file ?? process.env.AHPC_RECORD;
|
|
110
|
+
const write = (from, text) => {
|
|
105
111
|
if (recording === undefined || recording === '')
|
|
106
112
|
return;
|
|
113
|
+
// Parsed, so `jq` reads the file; kept as text when it is not JSON, since
|
|
114
|
+
// a frame that is not is exactly what a capture is for.
|
|
115
|
+
let frame = text;
|
|
107
116
|
try {
|
|
108
|
-
|
|
117
|
+
frame = JSON.parse(text);
|
|
118
|
+
}
|
|
119
|
+
catch { /* kept as text */ }
|
|
120
|
+
try {
|
|
121
|
+
appendFileSync(recording, `${JSON.stringify({ at: new Date().toISOString(), from, peer: wire.peer, frame })}\n`);
|
|
109
122
|
}
|
|
110
123
|
catch { /* a recording is a convenience, never a reason to fail a call */ }
|
|
111
124
|
};
|
|
@@ -119,7 +132,7 @@ function tee(inner, heard) {
|
|
|
119
132
|
const frame = await inner.recv();
|
|
120
133
|
if (frame === null)
|
|
121
134
|
return null;
|
|
122
|
-
write('host', frame.kind === 'text' ? frame.text : JSON.stringify(frame.message));
|
|
135
|
+
write('host', frame.kind === 'text' ? frame.text ?? '' : JSON.stringify(frame.message));
|
|
123
136
|
try {
|
|
124
137
|
const message = frame.kind === 'parsed'
|
|
125
138
|
? bag(frame.message)
|
|
@@ -302,17 +315,20 @@ function toolCall(value) {
|
|
|
302
315
|
const files = content
|
|
303
316
|
.map((entry) => str(bag(bag(entry).file).uri) ?? str(bag(entry).uri))
|
|
304
317
|
.filter((entry) => entry !== undefined);
|
|
318
|
+
const status = (str(call.status) ?? 'running');
|
|
319
|
+
const progress = status === 'running' ? plain(bag(call._meta).progressMessage) : undefined;
|
|
305
320
|
return {
|
|
306
321
|
id: str(call.toolCallId) ?? randomUUID(),
|
|
307
322
|
name: str(call.displayName) ?? str(call.toolName) ?? 'tool',
|
|
308
323
|
toolName: str(call.toolName) ?? 'tool',
|
|
309
|
-
status
|
|
324
|
+
status,
|
|
310
325
|
// A `ContentRef` is a promise of content rather than content: reporting
|
|
311
326
|
// nothing is better than reporting the reference as if it were the command.
|
|
312
327
|
...(typeof input === 'string' ? { input } : {}),
|
|
313
328
|
...(plain(call.intention) ?? plain(call.invocationMessage)
|
|
314
329
|
? { intention: (plain(call.intention) ?? plain(call.invocationMessage)) }
|
|
315
330
|
: {}),
|
|
331
|
+
...(progress !== undefined ? { progress } : {}),
|
|
316
332
|
...(plain(call.pastTenseMessage) ? { outcome: plain(call.pastTenseMessage) } : {}),
|
|
317
333
|
...(text ? { output: text } : {}),
|
|
318
334
|
...(files.length > 0 ? { files } : {}),
|
|
@@ -427,11 +443,15 @@ export function activityOf(status, asked, running, failed) {
|
|
|
427
443
|
*/
|
|
428
444
|
function transcript(chat) {
|
|
429
445
|
const out = [];
|
|
430
|
-
const
|
|
446
|
+
const built = (value, running) => {
|
|
447
|
+
const rows = [];
|
|
431
448
|
const found = bag(value);
|
|
449
|
+
const hidden = hiddenOf(bag(found.message));
|
|
450
|
+
if (hidden === 'turn')
|
|
451
|
+
return rows;
|
|
432
452
|
const said = str(bag(found.message).text);
|
|
433
|
-
if (said) {
|
|
434
|
-
|
|
453
|
+
if (said && hidden !== 'request') {
|
|
454
|
+
rows.push({
|
|
435
455
|
id: `${str(found.id) ?? ''}:said`,
|
|
436
456
|
role: 'user',
|
|
437
457
|
message: said,
|
|
@@ -442,14 +462,92 @@ function transcript(chat) {
|
|
|
442
462
|
}
|
|
443
463
|
const agent = turn(found, running);
|
|
444
464
|
delete agent.message;
|
|
445
|
-
|
|
465
|
+
rows.push(agent);
|
|
466
|
+
return rows;
|
|
446
467
|
};
|
|
447
|
-
|
|
448
|
-
|
|
468
|
+
const finished = list(chat.turns);
|
|
469
|
+
/*
|
|
470
|
+
* The whole finished prefix, when it is the same array it was last time.
|
|
471
|
+
*
|
|
472
|
+
* The per-turn cache below removes the building; this removes the walking.
|
|
473
|
+
* While a reply streams, only `activeTurn` changes - `turns` keeps its
|
|
474
|
+
* identity through every delta - so the history is copied once per turn
|
|
475
|
+
* rather than once per token, which is the difference between constant and
|
|
476
|
+
* linear on the path that runs most.
|
|
477
|
+
*/
|
|
478
|
+
const whole = typeof chat.turns === 'object' && chat.turns !== null
|
|
479
|
+
? prefixes.get(chat.turns)
|
|
480
|
+
: undefined;
|
|
481
|
+
if (whole !== undefined)
|
|
482
|
+
out.push(...whole);
|
|
483
|
+
else
|
|
484
|
+
for (const entry of finished) {
|
|
485
|
+
/*
|
|
486
|
+
* A finished turn is built once, and after that it is the same object.
|
|
487
|
+
*
|
|
488
|
+
* This runs after every reduced action, so a token arriving into a long
|
|
489
|
+
* conversation rebuilt every turn in it - measured at 2.5ms per token into
|
|
490
|
+
* two thousand turns, against a microsecond for the reduce that caused it.
|
|
491
|
+
*
|
|
492
|
+
* Keyed on the turn object rather than on its id, which is what makes the
|
|
493
|
+
* invalidation right by construction: the reducer is immutable, so a turn
|
|
494
|
+
* that changed is a new object and misses, and a turn that did not is the
|
|
495
|
+
* same one and hits. History loads, truncation and reconnect snapshots all
|
|
496
|
+
* build new objects, so there is nothing to remember to invalidate - which
|
|
497
|
+
* is the part of a cache like this that goes wrong.
|
|
498
|
+
*
|
|
499
|
+
* Weak, so nothing here keeps a conversation alive after the view on it
|
|
500
|
+
* has gone. Nothing mutates a `Turn` - every reader treats them as values -
|
|
501
|
+
* which is what makes handing the same array back twice safe.
|
|
502
|
+
*/
|
|
503
|
+
if (typeof entry === 'object' && entry !== null) {
|
|
504
|
+
const had = ready.get(entry);
|
|
505
|
+
if (had !== undefined) {
|
|
506
|
+
out.push(...had);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const made = built(entry, false);
|
|
510
|
+
ready.set(entry, made);
|
|
511
|
+
out.push(...made);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
out.push(...built(entry, false));
|
|
515
|
+
}
|
|
516
|
+
if (whole === undefined && typeof chat.turns === 'object' && chat.turns !== null) {
|
|
517
|
+
prefixes.set(chat.turns, [...out]);
|
|
518
|
+
}
|
|
519
|
+
// Never the running turn: it is a new object on every delta, so caching it
|
|
520
|
+
// would be a write per token and a hit never.
|
|
449
521
|
if (chat.activeTurn)
|
|
450
|
-
|
|
522
|
+
out.push(...built(chat.activeTurn, true));
|
|
451
523
|
return out;
|
|
452
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Whether the reference client would draw a message, and how much of it.
|
|
527
|
+
*
|
|
528
|
+
* Two well-known keys on a message's `_meta`, VS Code's own, each with a
|
|
529
|
+
* text-prefix spelling for a host that cannot write `_meta`: one hides the
|
|
530
|
+
* whole turn, the other only the request row and leaves the answer. They are
|
|
531
|
+
* how the editor keeps its own house out of the transcript - a "Couldn't open
|
|
532
|
+
* session" notice, an Agent Merge status - which the host appended as a turn
|
|
533
|
+
* because that is the one thing a host can append. Read at the projection
|
|
534
|
+
* rather than the view: a turn nobody would draw is a turn that is not there.
|
|
535
|
+
*/
|
|
536
|
+
function hiddenOf(message) {
|
|
537
|
+
const meta = bag(message._meta);
|
|
538
|
+
const text = str(message.text) ?? '';
|
|
539
|
+
if (meta['vscode.chat.hiddenFromTranscript'] === true
|
|
540
|
+
|| text.startsWith('<!-- vscode-hidden-from-transcript -->\n'))
|
|
541
|
+
return 'turn';
|
|
542
|
+
if (meta['vscode.chat.requestHiddenFromTranscript'] === true
|
|
543
|
+
|| text.startsWith('<!-- vscode-request-hidden-from-transcript -->\n'))
|
|
544
|
+
return 'request';
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
/** What `transcript` has already built for a finished turn. See the note in it. */
|
|
548
|
+
const ready = new WeakMap();
|
|
549
|
+
/** And for a whole `turns` array, which is what a streaming reply does not change. */
|
|
550
|
+
const prefixes = new WeakMap();
|
|
453
551
|
const KINDS = {
|
|
454
552
|
text: 'text', number: 'number', integer: 'integer', boolean: 'boolean',
|
|
455
553
|
'single-select': 'single-select', 'multi-select': 'multi-select',
|
|
@@ -665,10 +763,17 @@ function config(value) {
|
|
|
665
763
|
})),
|
|
666
764
|
sessionMutable: property.sessionMutable === true,
|
|
667
765
|
...(property.enumDynamic === true ? { enumDynamic: true } : {}),
|
|
766
|
+
...(property.readOnly === true ? { readOnly: true } : {}),
|
|
668
767
|
...(str(property.default) ? { default: str(property.default) } : {}),
|
|
669
768
|
};
|
|
670
769
|
}),
|
|
671
|
-
|
|
770
|
+
// What is on a chip, and what goes back to the host as answered: one
|
|
771
|
+
// value each. `permissions` is an object and `shellInitScripts` a list,
|
|
772
|
+
// and neither is a control here - stringified they went back to the host
|
|
773
|
+
// as `[object Object]`, an answer to a question nobody was asked.
|
|
774
|
+
values: Object.fromEntries(Object.entries(values)
|
|
775
|
+
.filter(([, entry]) => typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean')
|
|
776
|
+
.map(([key, entry]) => [key, String(entry)])),
|
|
672
777
|
};
|
|
673
778
|
}
|
|
674
779
|
/**
|
|
@@ -918,6 +1023,7 @@ export async function liveHost(options) {
|
|
|
918
1023
|
*/
|
|
919
1024
|
const clientId = options.clientId ?? `ahpc-${randomUUID().slice(0, 8)}`;
|
|
920
1025
|
const openTransport = options.connect ?? (() => ahp.connect(endpoint));
|
|
1026
|
+
const wiring = { ...(options.wire !== undefined ? { file: options.wire } : {}), peer: options.url };
|
|
921
1027
|
const backoff = options.backoff ?? BACKOFF;
|
|
922
1028
|
const keepaliveMs = options.keepaliveMs ?? KEEPALIVE_MS;
|
|
923
1029
|
/**
|
|
@@ -973,7 +1079,7 @@ export async function liveHost(options) {
|
|
|
973
1079
|
working.set(token, `${said}${share}`);
|
|
974
1080
|
options.onProgress?.(token, `${said}${share}`);
|
|
975
1081
|
};
|
|
976
|
-
const transport = tee(await openTransport(), notified);
|
|
1082
|
+
const transport = tee(await openTransport(), notified, wiring);
|
|
977
1083
|
let client = new ahp.Client(transport, {});
|
|
978
1084
|
/*
|
|
979
1085
|
* What a host may ask this client for.
|
|
@@ -1115,20 +1221,19 @@ export async function liveHost(options) {
|
|
|
1115
1221
|
* become a different answer on the next keystroke.
|
|
1116
1222
|
*/
|
|
1117
1223
|
/**
|
|
1118
|
-
* The automations catalogue, under whichever name the host
|
|
1224
|
+
* The automations catalogue, under whichever name the host gives it.
|
|
1119
1225
|
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
* `
|
|
1124
|
-
*
|
|
1125
|
-
* themselves
|
|
1126
|
-
*
|
|
1127
|
-
* kept its name and its shape.
|
|
1226
|
+
* The protocol's `AutomationState` puts the automations in `entries`. For
|
|
1227
|
+
* two weeks VS Code's vendored copy called the catalogue
|
|
1228
|
+
* `AutomationCatalogState` with the field named `automations`, and moved
|
|
1229
|
+
* the name `AutomationState` onto a single automation; it has since gone
|
|
1230
|
+
* back, but Insiders builds from that window are still out there, and the
|
|
1231
|
+
* automations themselves never moved - the same fields either way, and
|
|
1232
|
+
* every action on the channel kept its name and its shape.
|
|
1128
1233
|
*
|
|
1129
1234
|
* So one field is normalised here, at the edge, and everything past this
|
|
1130
|
-
* point - the reducer included, which
|
|
1131
|
-
*
|
|
1235
|
+
* point - the reducer included, which reads `entries` - carries on unaware
|
|
1236
|
+
* there was ever a second spelling. It costs one line to keep.
|
|
1132
1237
|
*/
|
|
1133
1238
|
const automationCatalogue = (state) => {
|
|
1134
1239
|
if (state === null)
|
|
@@ -1263,7 +1368,7 @@ export async function liveHost(options) {
|
|
|
1263
1368
|
if (finished)
|
|
1264
1369
|
return;
|
|
1265
1370
|
try {
|
|
1266
|
-
const socket = tee(await openTransport(), notified);
|
|
1371
|
+
const socket = tee(await openTransport(), notified, wiring);
|
|
1267
1372
|
const fresh = new ahp.Client(socket, {});
|
|
1268
1373
|
fresh.setServerRequestHandler(answering);
|
|
1269
1374
|
fresh.connect();
|
|
@@ -1438,7 +1543,12 @@ export async function liveHost(options) {
|
|
|
1438
1543
|
*/
|
|
1439
1544
|
resourceList: async (uri) => {
|
|
1440
1545
|
const result = bag(await client.request('resourceList', { channel: ROOT, uri }));
|
|
1441
|
-
|
|
1546
|
+
// A root's trailing slash is its whole path: `file:///` trimmed like a
|
|
1547
|
+
// folder is `file:`, and every entry under it - and every folder walked
|
|
1548
|
+
// to from there - was `file:/name`, which no host lists or starts a
|
|
1549
|
+
// session in.
|
|
1550
|
+
const root = /^[a-z][\w+.-]*:\/*$/i.test(uri);
|
|
1551
|
+
const parent = root ? uri.replace(/\/*$/, '//') : uri.replace(/\/+$/, '');
|
|
1442
1552
|
return list(result.entries).map((raw) => {
|
|
1443
1553
|
const entry = bag(raw);
|
|
1444
1554
|
return {
|
|
@@ -2126,7 +2236,10 @@ export async function liveHost(options) {
|
|
|
2126
2236
|
turns: all.filter((found) => found !== active),
|
|
2127
2237
|
...(active ? { active } : {}),
|
|
2128
2238
|
...(asked ? { input: asked } : {}),
|
|
2129
|
-
status: activityOf(typeof session.status === 'number' ? session.status : 1, Boolean(asked),
|
|
2239
|
+
status: activityOf(typeof session.status === 'number' ? session.status : 1, Boolean(asked),
|
|
2240
|
+
// From the wire, not from `active`: a running turn the reference
|
|
2241
|
+
// client hides is still a turn the session is working on.
|
|
2242
|
+
Boolean(chat.activeTurn), all[all.length - 1]?.state === 'failed'),
|
|
2130
2243
|
queued: queued(chat),
|
|
2131
2244
|
// What the host is holding as the message being composed. Shared
|
|
2132
2245
|
// state: another client typing here is visible, and it outlives
|
package/dist/src/ahp/publish.js
CHANGED
|
@@ -188,6 +188,13 @@ export function publish(options = {}) {
|
|
|
188
188
|
flags |= constants.O_CREAT;
|
|
189
189
|
if (createOnly && ifMatch === undefined)
|
|
190
190
|
flags |= constants.O_EXCL;
|
|
191
|
+
// `O_NOFOLLOW` is what refuses a final link, and Windows has no such
|
|
192
|
+
// flag: `constants.O_NOFOLLOW` is undefined there and the `|` above is a
|
|
193
|
+
// no-op. That end is asked about the link first, which is the best it
|
|
194
|
+
// offers; where the flag exists the open itself is the check.
|
|
195
|
+
if (constants.O_NOFOLLOW === undefined && await lstat(at).then((found) => found.isSymbolicLink(), () => false)) {
|
|
196
|
+
throw new PublishRefusal(PERMISSION_DENIED, `${String(uri)} is a symbolic link.`);
|
|
197
|
+
}
|
|
191
198
|
const file = await open(at, flags).catch((error) => {
|
|
192
199
|
if (createOnly && error.code === 'EEXIST') {
|
|
193
200
|
throw new PublishRefusal(ALREADY_EXISTS, `${String(uri)} already exists.`);
|
|
@@ -210,6 +217,12 @@ export function publish(options = {}) {
|
|
|
210
217
|
throw new PublishRefusal(PERMISSION_DENIED, `Could not write ${String(uri)}: ${error.message}`);
|
|
211
218
|
});
|
|
212
219
|
try {
|
|
220
|
+
// Windows opens a directory for writing and fails at the first write
|
|
221
|
+
// instead, with an `EISDIR` the catch above never sees; asked here so
|
|
222
|
+
// both ends refuse before anything is touched.
|
|
223
|
+
if (process.platform === 'win32' && (await file.stat()).isDirectory()) {
|
|
224
|
+
throw new PublishRefusal(PERMISSION_DENIED, `${String(uri)} is a directory.`);
|
|
225
|
+
}
|
|
213
226
|
if (createOnly && ifMatch !== undefined) {
|
|
214
227
|
throw new PublishRefusal(ALREADY_EXISTS, `${String(uri)} already exists.`);
|
|
215
228
|
}
|
package/dist/src/ahp/types.d.ts
CHANGED
|
@@ -141,6 +141,16 @@ export interface ToolCall {
|
|
|
141
141
|
input?: string;
|
|
142
142
|
/** What it meant to do. Markdown. */
|
|
143
143
|
intention?: string;
|
|
144
|
+
/**
|
|
145
|
+
* What it is doing right now, while it runs.
|
|
146
|
+
*
|
|
147
|
+
* `_meta.progressMessage`, the reference host's key for a line drawn on a
|
|
148
|
+
* running row and dropped when the row ends: a subagent's own summary of
|
|
149
|
+
* how far it has got, the last tool it reached for. Read only while
|
|
150
|
+
* `running`; a host that leaves it on a finished call is still describing
|
|
151
|
+
* a state the call is no longer in.
|
|
152
|
+
*/
|
|
153
|
+
progress?: string;
|
|
144
154
|
/** What it did, past tense. */
|
|
145
155
|
outcome?: string;
|
|
146
156
|
/** What came back. */
|
|
@@ -572,6 +582,16 @@ export interface ConfigProperty {
|
|
|
572
582
|
* the schema says "ask me" and `sessionConfigCompletions` is the asking.
|
|
573
583
|
*/
|
|
574
584
|
enumDynamic?: boolean;
|
|
585
|
+
/**
|
|
586
|
+
* Shown, never asked.
|
|
587
|
+
*
|
|
588
|
+
* The reference host puts this on a value the client seeds rather than a
|
|
589
|
+
* person picks - a branch prefix, whether a worktree gets a branch of its
|
|
590
|
+
* own - and on `branch` while isolation is `folder`, where the checkout
|
|
591
|
+
* decides and a control would change nothing. Offered anyway it produces a
|
|
592
|
+
* refusal, not an edit.
|
|
593
|
+
*/
|
|
594
|
+
readOnly?: boolean;
|
|
575
595
|
/**
|
|
576
596
|
* What the host opens with, where it said.
|
|
577
597
|
*
|