@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.
Files changed (73) hide show
  1. package/README.md +37 -6
  2. package/dist/src/ahp/fake.js +61 -14
  3. package/dist/src/ahp/live.d.ts +7 -0
  4. package/dist/src/ahp/live.js +156 -43
  5. package/dist/src/ahp/publish.js +13 -0
  6. package/dist/src/ahp/types.d.ts +20 -0
  7. package/dist/src/app.js +34 -7
  8. package/dist/src/blocks.d.ts +4 -74
  9. package/dist/src/blocks.js +10 -48
  10. package/dist/src/cli/main.d.ts +1 -1
  11. package/dist/src/cli/main.js +109 -6
  12. package/dist/src/connect.d.ts +2 -0
  13. package/dist/src/connect.js +1 -0
  14. package/dist/src/control.d.ts +6 -0
  15. package/dist/src/control.js +140 -10
  16. package/dist/src/flags.js +4 -1
  17. package/dist/src/links.d.ts +54 -0
  18. package/dist/src/links.js +120 -0
  19. package/dist/src/main.js +14 -2
  20. package/dist/src/mcp/http.d.ts +10 -0
  21. package/dist/src/mcp/http.js +110 -3
  22. package/dist/src/mcp/serve.d.ts +47 -9
  23. package/dist/src/mcp/serve.js +65 -14
  24. package/dist/src/mcp/stdio.d.ts +1 -0
  25. package/dist/src/mcp/stdio.js +7 -1
  26. package/dist/src/mcp/tools.d.ts +37 -2
  27. package/dist/src/mcp/tools.js +439 -2
  28. package/dist/src/resources.d.ts +13 -0
  29. package/dist/src/resources.js +46 -0
  30. package/dist/src/screens.js +95 -50
  31. package/dist/src/state.d.ts +42 -0
  32. package/dist/src/state.js +80 -1
  33. package/dist/src/tui.d.ts +3 -1
  34. package/dist/src/tui.js +29 -4
  35. package/dist/src/version.d.ts +2 -0
  36. package/dist/src/version.js +38 -0
  37. package/dist/src/view/creature.d.ts +0 -12
  38. package/dist/src/view/creature.js +0 -20
  39. package/dist/src/view/wire.d.ts +36 -0
  40. package/dist/src/view/wire.js +196 -0
  41. package/dist/src/wait.d.ts +11 -0
  42. package/dist/src/wait.js +15 -2
  43. package/dist/src/wire.d.ts +70 -0
  44. package/dist/src/wire.js +194 -0
  45. package/dist/src/wiretui.d.ts +22 -0
  46. package/dist/src/wiretui.js +69 -0
  47. package/package.json +6 -5
  48. package/dist/src/diff.d.ts +0 -44
  49. package/dist/src/diff.js +0 -111
  50. package/dist/src/view/bubble.d.ts +0 -75
  51. package/dist/src/view/bubble.js +0 -86
  52. package/dist/src/view/composer.d.ts +0 -64
  53. package/dist/src/view/composer.js +0 -192
  54. package/dist/src/view/controls.d.ts +0 -44
  55. package/dist/src/view/controls.js +0 -49
  56. package/dist/src/view/details.d.ts +0 -65
  57. package/dist/src/view/details.js +0 -65
  58. package/dist/src/view/filediff.d.ts +0 -29
  59. package/dist/src/view/filediff.js +0 -24
  60. package/dist/src/view/hitl.d.ts +0 -43
  61. package/dist/src/view/hitl.js +0 -171
  62. package/dist/src/view/icons.d.ts +0 -13
  63. package/dist/src/view/icons.js +0 -71
  64. package/dist/src/view/picker.d.ts +0 -42
  65. package/dist/src/view/picker.js +0 -71
  66. package/dist/src/view/sessionhead.d.ts +0 -41
  67. package/dist/src/view/sessionhead.js +0 -60
  68. package/dist/src/view/sessions.d.ts +0 -34
  69. package/dist/src/view/sessions.js +0 -61
  70. package/dist/src/view/toolcall.d.ts +0 -27
  71. package/dist/src/view/toolcall.js +0 -48
  72. package/dist/src/view/transcript.d.ts +0 -50
  73. package/dist/src/view/transcript.js +0 -60
package/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # ahpc
2
2
 
3
+ [![CI](https://github.com/softov/ahpc/actions/workflows/ci.yml/badge.svg)](https://github.com/softov/ahpc/actions/workflows/ci.yml)
3
4
  [![npm](https://img.shields.io/npm/v/%40softov%2Fahpc)](https://www.npmjs.com/package/@softov/ahpc)
4
5
  ![license MIT](https://img.shields.io/badge/license-MIT-blue)
5
6
  ![node >=22](https://img.shields.io/badge/node-%3E%3D22-5fa04e)
@@ -73,7 +74,7 @@ Inside a session, the header shows the model, thinking level, permission mode, w
73
74
 
74
75
  ![Starting a session, and the questions the host asks first](docs/img/compose.svg)
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 row.
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 to the options row |
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
 
@@ -17,7 +17,16 @@ const EFFORTS = [
17
17
  { value: 'xhigh', label: 'Extra High' },
18
18
  { value: 'max', label: 'Max' },
19
19
  ];
20
- const CONFIG = [
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
- resolveConfig: async ({ values }) => ({
1131
- properties: CONFIG,
1132
- values: { permissionMode: 'default', isolation: 'workspace', ...values },
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.
@@ -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
  /**
@@ -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 first entry it also speaks, so this is a preference rather
52
- * than a floor. Offering one the installed library has no types for is safe:
53
- * every command used here is stable across all of them.
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` is not published - VS Code's host vendors the protocol from its
56
- * repository and runs ahead of npm - and it accepts `^1.0.0` and nothing 0.x.
57
- * Leaving it out is therefore not the conservative choice: it is every entry
58
- * refused with `-32005`, which arrives here looking like a host that is not
59
- * there. `0.9.0` is the newest published, and the version the package below
60
- * is built from.
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 = ['1.0.0', '0.9.0', '0.8.0', '0.7.0'];
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. Appended synchronously and deliberately - a
100
- * recording that lost the frame a crash happened on would be a recording of
101
- * everything except the interesting part.
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, frame) => {
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
- appendFileSync(recording, `${JSON.stringify({ at: new Date().toISOString(), from, frame })}\n`);
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: (str(call.status) ?? 'running'),
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 add = (value, running) => {
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
- out.push({
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
- out.push(agent);
465
+ rows.push(agent);
466
+ return rows;
446
467
  };
447
- for (const entry of list(chat.turns))
448
- add(entry, false);
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
- add(chat.activeTurn, true);
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
- values: Object.fromEntries(Object.entries(values).map(([key, entry]) => [key, String(entry)])),
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's version gives it.
1224
+ * The automations catalogue, under whichever name the host gives it.
1119
1225
  *
1120
- * This is the whole of what changed between protocol 0.9.0 and 1.0.0 in
1121
- * anything this client reads. 0.9.0 calls the catalogue `AutomationState`
1122
- * and puts the automations in `entries`; 1.0.0 renames the catalogue to
1123
- * `AutomationCatalogState` and the field to `automations`, and moves the
1124
- * name `AutomationState` onto a single automation. The automations
1125
- * themselves did not move - 0.9.0's `AutomationEntry` and 1.0.0's
1126
- * `AutomationState` have the same fields, and every action on the channel
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 is the 0.9.0 one and reads `entries` -
1131
- * carries on unaware there was ever a second spelling.
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
- const parent = uri.replace(/\/+$/, '');
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), active !== undefined, all[all.length - 1]?.state === 'failed'),
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
@@ -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
  }
@@ -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
  *