@softov/ahpc 0.3.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 (60) hide show
  1. package/README.md +6 -4
  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 +93 -37
  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 +75 -2
  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 +1 -1
  17. package/dist/src/links.d.ts +54 -0
  18. package/dist/src/links.js +120 -0
  19. package/dist/src/resources.d.ts +13 -0
  20. package/dist/src/resources.js +46 -0
  21. package/dist/src/screens.js +95 -50
  22. package/dist/src/state.d.ts +42 -0
  23. package/dist/src/state.js +80 -1
  24. package/dist/src/tui.d.ts +3 -1
  25. package/dist/src/tui.js +28 -4
  26. package/dist/src/view/creature.d.ts +0 -12
  27. package/dist/src/view/creature.js +0 -20
  28. package/dist/src/view/wire.d.ts +36 -0
  29. package/dist/src/view/wire.js +196 -0
  30. package/dist/src/wire.d.ts +70 -0
  31. package/dist/src/wire.js +194 -0
  32. package/dist/src/wiretui.d.ts +22 -0
  33. package/dist/src/wiretui.js +69 -0
  34. package/package.json +6 -5
  35. package/dist/src/diff.d.ts +0 -44
  36. package/dist/src/diff.js +0 -111
  37. package/dist/src/view/bubble.d.ts +0 -75
  38. package/dist/src/view/bubble.js +0 -86
  39. package/dist/src/view/composer.d.ts +0 -64
  40. package/dist/src/view/composer.js +0 -192
  41. package/dist/src/view/controls.d.ts +0 -44
  42. package/dist/src/view/controls.js +0 -49
  43. package/dist/src/view/details.d.ts +0 -65
  44. package/dist/src/view/details.js +0 -65
  45. package/dist/src/view/filediff.d.ts +0 -29
  46. package/dist/src/view/filediff.js +0 -24
  47. package/dist/src/view/hitl.d.ts +0 -43
  48. package/dist/src/view/hitl.js +0 -171
  49. package/dist/src/view/icons.d.ts +0 -13
  50. package/dist/src/view/icons.js +0 -71
  51. package/dist/src/view/picker.d.ts +0 -42
  52. package/dist/src/view/picker.js +0 -71
  53. package/dist/src/view/sessionhead.d.ts +0 -41
  54. package/dist/src/view/sessionhead.js +0 -60
  55. package/dist/src/view/sessions.d.ts +0 -34
  56. package/dist/src/view/sessions.js +0 -61
  57. package/dist/src/view/toolcall.d.ts +0 -27
  58. package/dist/src/view/toolcall.js +0 -48
  59. package/dist/src/view/transcript.d.ts +0 -50
  60. package/dist/src/view/transcript.js +0 -60
package/README.md CHANGED
@@ -74,7 +74,7 @@ Inside a session, the header shows the model, thinking level, permission mode, w
74
74
 
75
75
  ![Starting a session, and the questions the host asks first](docs/img/compose.svg)
76
76
 
77
- 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.
78
78
 
79
79
  ### Keys
80
80
 
@@ -82,14 +82,15 @@ Before the first message, a new session asks the agent, the model and its option
82
82
  |---|---|
83
83
  | `enter` | Send |
84
84
  | `alt+enter` | Newline |
85
- | `tab` | Move to the options row |
86
- | `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 |
87
87
  | `/` | Slash commands, from the host and from `ahpc` — `/config` opens the settings palette |
88
88
  | `@` | Complete a file path on the host |
89
89
  | `ctrl+g` | Edit the message in `$VISUAL` or `$EDITOR` |
90
90
  | `ctrl+p` | Command palette |
91
91
  | `f1` | Every key that works where you are |
92
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 |
93
94
  | `ctrl+n` | New session |
94
95
  | `ctrl+r` | Refresh |
95
96
  | `alt+t` | Theme |
@@ -223,6 +224,7 @@ Writes are guarded by the file's etag unless you pass `--force`, so two clients
223
224
  | Command | | |
224
225
  |---|---|---|
225
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` |
226
228
  | `config` | Show the config file path and current values | `--json` |
227
229
  | `--version` | What version this is | |
228
230
  | `help` | Print this command list | |
@@ -353,7 +355,7 @@ npm run schema # a strict JSON Schema from the package's own declar
353
355
  npm run wire -- <capture> # check a recording against it
354
356
  ```
355
357
 
356
- `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.
357
359
 
358
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.
359
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 } : {}),
@@ -430,8 +446,11 @@ function transcript(chat) {
430
446
  const built = (value, running) => {
431
447
  const rows = [];
432
448
  const found = bag(value);
449
+ const hidden = hiddenOf(bag(found.message));
450
+ if (hidden === 'turn')
451
+ return rows;
433
452
  const said = str(bag(found.message).text);
434
- if (said) {
453
+ if (said && hidden !== 'request') {
435
454
  rows.push({
436
455
  id: `${str(found.id) ?? ''}:said`,
437
456
  role: 'user',
@@ -503,6 +522,28 @@ function transcript(chat) {
503
522
  out.push(...built(chat.activeTurn, true));
504
523
  return out;
505
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
+ }
506
547
  /** What `transcript` has already built for a finished turn. See the note in it. */
507
548
  const ready = new WeakMap();
508
549
  /** And for a whole `turns` array, which is what a streaming reply does not change. */
@@ -722,10 +763,17 @@ function config(value) {
722
763
  })),
723
764
  sessionMutable: property.sessionMutable === true,
724
765
  ...(property.enumDynamic === true ? { enumDynamic: true } : {}),
766
+ ...(property.readOnly === true ? { readOnly: true } : {}),
725
767
  ...(str(property.default) ? { default: str(property.default) } : {}),
726
768
  };
727
769
  }),
728
- 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)])),
729
777
  };
730
778
  }
731
779
  /**
@@ -975,6 +1023,7 @@ export async function liveHost(options) {
975
1023
  */
976
1024
  const clientId = options.clientId ?? `ahpc-${randomUUID().slice(0, 8)}`;
977
1025
  const openTransport = options.connect ?? (() => ahp.connect(endpoint));
1026
+ const wiring = { ...(options.wire !== undefined ? { file: options.wire } : {}), peer: options.url };
978
1027
  const backoff = options.backoff ?? BACKOFF;
979
1028
  const keepaliveMs = options.keepaliveMs ?? KEEPALIVE_MS;
980
1029
  /**
@@ -1030,7 +1079,7 @@ export async function liveHost(options) {
1030
1079
  working.set(token, `${said}${share}`);
1031
1080
  options.onProgress?.(token, `${said}${share}`);
1032
1081
  };
1033
- const transport = tee(await openTransport(), notified);
1082
+ const transport = tee(await openTransport(), notified, wiring);
1034
1083
  let client = new ahp.Client(transport, {});
1035
1084
  /*
1036
1085
  * What a host may ask this client for.
@@ -1172,20 +1221,19 @@ export async function liveHost(options) {
1172
1221
  * become a different answer on the next keystroke.
1173
1222
  */
1174
1223
  /**
1175
- * The automations catalogue, under whichever name the host's version gives it.
1224
+ * The automations catalogue, under whichever name the host gives it.
1176
1225
  *
1177
- * This is the whole of what changed between protocol 0.9.0 and 1.0.0 in
1178
- * anything this client reads. 0.9.0 calls the catalogue `AutomationState`
1179
- * and puts the automations in `entries`; 1.0.0 renames the catalogue to
1180
- * `AutomationCatalogState` and the field to `automations`, and moves the
1181
- * name `AutomationState` onto a single automation. The automations
1182
- * themselves did not move - 0.9.0's `AutomationEntry` and 1.0.0's
1183
- * `AutomationState` have the same fields, and every action on the channel
1184
- * 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.
1185
1233
  *
1186
1234
  * So one field is normalised here, at the edge, and everything past this
1187
- * point - the reducer included, which is the 0.9.0 one and reads `entries` -
1188
- * 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.
1189
1237
  */
1190
1238
  const automationCatalogue = (state) => {
1191
1239
  if (state === null)
@@ -1320,7 +1368,7 @@ export async function liveHost(options) {
1320
1368
  if (finished)
1321
1369
  return;
1322
1370
  try {
1323
- const socket = tee(await openTransport(), notified);
1371
+ const socket = tee(await openTransport(), notified, wiring);
1324
1372
  const fresh = new ahp.Client(socket, {});
1325
1373
  fresh.setServerRequestHandler(answering);
1326
1374
  fresh.connect();
@@ -1495,7 +1543,12 @@ export async function liveHost(options) {
1495
1543
  */
1496
1544
  resourceList: async (uri) => {
1497
1545
  const result = bag(await client.request('resourceList', { channel: ROOT, uri }));
1498
- 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(/\/+$/, '');
1499
1552
  return list(result.entries).map((raw) => {
1500
1553
  const entry = bag(raw);
1501
1554
  return {
@@ -2183,7 +2236,10 @@ export async function liveHost(options) {
2183
2236
  turns: all.filter((found) => found !== active),
2184
2237
  ...(active ? { active } : {}),
2185
2238
  ...(asked ? { input: asked } : {}),
2186
- 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'),
2187
2243
  queued: queued(chat),
2188
2244
  // What the host is holding as the message being composed. Shared
2189
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
  *
package/dist/src/app.js CHANGED
@@ -3,19 +3,15 @@ import { createBag, defineComponent, useApp, useTheme, useStoreSubtree, useStore
3
3
  import { KeyHints, Row, registerBuiltins } from '@textui/widgets';
4
4
  import { CONTROLLER, createController } from './control.js';
5
5
  import { fakeHost } from './ahp/fake.js';
6
+ import { hostResources } from './resources.js';
6
7
  import { BOOD, BOOD_FLOAT, BOOD_FLOOR, BOOD_INLINE, FOCUS, HOST, HOST_ERROR, INPUT, INPUT_STATUS, OPEN, RUNNING, SCREEN, SESSIONS, SPLIT_AT, SPLIT_DEFAULT, STATUS, WORKSPACE, boodFloor, openSession, workspaceName, } from './state.js';
7
8
  import { decodeStatus } from './ahp/status.js';
8
9
  import { AutomationsScreen, ChangesScreen, ChatScreen, FilesScreen, HostsScreen, McpScreen, NewAutomationScreen, NewSessionScreen, SessionsScreen, TerminalScreen, SettingsScreen, SkillsScreen, } from './screens.js';
9
- import { ChatBubble, ReasoningBlock, StreamingText } from './view/bubble.js';
10
- import { ChatComposer } from './view/composer.js';
11
- import { ChatHitl } from './view/hitl.js';
12
- import { ChatTranscript } from './view/transcript.js';
10
+ import { ChatBubble, ChatComposer, ChatHitl, ChatTranscript, ConnectionBadge, ReasoningBlock, SEND_ID, SessionList, StreamingText, ToolCallRow, } from '@textui/chat';
13
11
  import { BoodSprite, Creature, moodOf, pickBood } from './view/creature.js';
14
12
  import { ChangesList } from './view/changes.js';
15
13
  import { FileList } from './view/files.js';
16
14
  import { AutomationList } from './view/automations.js';
17
- import { ConnectionBadge, SessionList } from './view/sessions.js';
18
- import { ToolCallRow } from './view/toolcall.js';
19
15
  /**
20
16
  * A chat client for an agent host.
21
17
  *
@@ -124,6 +120,7 @@ const Header = defineComponent('ChatHeader', () => {
124
120
  */
125
121
  const Hints = defineComponent('ChatHints', (props) => {
126
122
  const theme = useTheme();
123
+ const app = useApp();
127
124
  /**
128
125
  * Which key the footer names for a newline.
129
126
  *
@@ -153,8 +150,19 @@ const Hints = defineComponent('ChatHints', (props) => {
153
150
  // it, escape leaves the field; from the transcript, escape leaves the
154
151
  // screen - and a hint row that said one of those in both places is wrong
155
152
  // half the time.
156
- const focused = useStoreValue(FOCUS, null);
153
+ const focused = useStoreValue(FOCUS, null) ?? null;
157
154
  const composing = focused === 'chat.composer';
155
+ // The control rows under the field are their own place: enter there opens
156
+ // a chip's panel or sends, and "alt+enter newline" is a key for a field
157
+ // the keyboard has left.
158
+ const onChip = focused !== null && focused.startsWith('chat.option.');
159
+ const onSend = focused === SEND_ID;
160
+ // A panel over the screen - a chip's picker, the palette - holds the
161
+ // keyboard, and the keys are the panel's until it closes. Read off the
162
+ // layers when the focus moves, which a panel that traps it does on the way
163
+ // in and on the way out; the layers themselves are not something a
164
+ // component can subscribe to.
165
+ const inPanel = app.layers.entries().some((entry) => entry.trapFocus === true);
158
166
  // A question is not a confirmation, and the keys are not the same either.
159
167
  // Offering "a approve" over an elicitation is the same mistake as rendering
160
168
  // one as the other, made in the one row that is supposed to explain it.
@@ -174,6 +182,22 @@ const Hints = defineComponent('ChatHints', (props) => {
174
182
  { keys: 'esc', label: 'read' },
175
183
  ] }));
176
184
  }
185
+ if (inPanel) {
186
+ return (_jsx(KeyHints, { ...props, hints: [
187
+ { keys: upDown, label: 'move' },
188
+ { keys: 'enter', label: 'choose' },
189
+ { keys: 'esc', label: 'back' },
190
+ { keys: 'ctrl+c', label: running ? 'stop' : 'quit' },
191
+ ] }));
192
+ }
193
+ if ((screen === 'chat' || screen === 'new') && (onChip || onSend)) {
194
+ return (_jsx(KeyHints, { ...props, hints: [
195
+ { keys: 'enter', label: onSend ? (screen === 'new' ? 'start' : running ? 'queue' : 'send') : 'open' },
196
+ { keys: 'tab', label: 'next option' },
197
+ { keys: 'esc', label: 'write' },
198
+ { keys: 'ctrl+c', label: running ? 'stop' : 'quit' },
199
+ ] }));
200
+ }
177
201
  if (screen === 'chat') {
178
202
  return (_jsx(KeyHints, { ...props, hints: composing
179
203
  ? [
@@ -293,6 +317,9 @@ export function registerChat(app, options = {}) {
293
317
  app.store.set(BOOD_FLOAT, options.boodFloat ?? false);
294
318
  bag.add(controller);
295
319
  bag.add(app.services.provide(CONTROLLER, controller));
320
+ // `file:` is the host's disk from here on, for the picker and anything
321
+ // else in textui that reads the resource registry.
322
+ bag.add(app.resources.registerProvider(hostResources(host)));
296
323
  for (const [component, render] of [
297
324
  ['ChatBubble', ChatBubble],
298
325
  ['StreamingText', StreamingText],