@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.
- package/README.md +6 -4
- package/dist/src/ahp/fake.js +61 -14
- package/dist/src/ahp/live.d.ts +7 -0
- package/dist/src/ahp/live.js +93 -37
- 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 +75 -2
- 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 +1 -1
- package/dist/src/links.d.ts +54 -0
- package/dist/src/links.js +120 -0
- 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 +28 -4
- 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/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
|
@@ -74,7 +74,7 @@ Inside a session, the header shows the model, thinking level, permission mode, w
|
|
|
74
74
|
|
|
75
75
|

|
|
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
|
|
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
|
|
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
|
|
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 } : {}),
|
|
@@ -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
|
-
|
|
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
|
|
1224
|
+
* The automations catalogue, under whichever name the host gives it.
|
|
1176
1225
|
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
* `
|
|
1181
|
-
*
|
|
1182
|
-
* themselves
|
|
1183
|
-
*
|
|
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
|
|
1188
|
-
*
|
|
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
|
-
|
|
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),
|
|
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
|
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
|
*
|
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 '
|
|
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],
|