@softov/ahpc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/src/ahp/channels.d.ts +146 -0
- package/dist/src/ahp/channels.js +423 -0
- package/dist/src/ahp/connection.d.ts +656 -0
- package/dist/src/ahp/connection.js +1 -0
- package/dist/src/ahp/fake.d.ts +69 -0
- package/dist/src/ahp/fake.js +1996 -0
- package/dist/src/ahp/live.d.ts +160 -0
- package/dist/src/ahp/live.js +2641 -0
- package/dist/src/ahp/operate.d.ts +36 -0
- package/dist/src/ahp/operate.js +56 -0
- package/dist/src/ahp/publish.d.ts +39 -0
- package/dist/src/ahp/publish.js +370 -0
- package/dist/src/ahp/status.d.ts +23 -0
- package/dist/src/ahp/status.js +23 -0
- package/dist/src/ahp/types.d.ts +745 -0
- package/dist/src/ahp/types.js +27 -0
- package/dist/src/app.d.ts +54 -0
- package/dist/src/app.js +369 -0
- package/dist/src/blocks.d.ts +88 -0
- package/dist/src/blocks.js +102 -0
- package/dist/src/cli/main.d.ts +13 -0
- package/dist/src/cli/main.js +1537 -0
- package/dist/src/cli/render.d.ts +43 -0
- package/dist/src/cli/render.js +91 -0
- package/dist/src/config.d.ts +63 -0
- package/dist/src/config.js +45 -0
- package/dist/src/connect.d.ts +53 -0
- package/dist/src/connect.js +69 -0
- package/dist/src/control.d.ts +189 -0
- package/dist/src/control.js +2241 -0
- package/dist/src/diff.d.ts +44 -0
- package/dist/src/diff.js +111 -0
- package/dist/src/flags.d.ts +28 -0
- package/dist/src/flags.js +76 -0
- package/dist/src/main.d.ts +10 -0
- package/dist/src/main.js +54 -0
- package/dist/src/schedule.d.ts +46 -0
- package/dist/src/schedule.js +140 -0
- package/dist/src/screens.d.ts +79 -0
- package/dist/src/screens.js +1322 -0
- package/dist/src/state.d.ts +355 -0
- package/dist/src/state.js +531 -0
- package/dist/src/terminal.d.ts +44 -0
- package/dist/src/terminal.js +101 -0
- package/dist/src/tui.d.ts +92 -0
- package/dist/src/tui.js +372 -0
- package/dist/src/view/automations.d.ts +31 -0
- package/dist/src/view/automations.js +76 -0
- package/dist/src/view/bood/art.d.ts +90 -0
- package/dist/src/view/bood/art.js +152 -0
- package/dist/src/view/bood/beetle.d.ts +7 -0
- package/dist/src/view/bood/beetle.js +123 -0
- package/dist/src/view/bood/bunny.d.ts +8 -0
- package/dist/src/view/bood/bunny.js +228 -0
- package/dist/src/view/bood/cat.d.ts +9 -0
- package/dist/src/view/bood/cat.js +358 -0
- package/dist/src/view/bood/crab.d.ts +7 -0
- package/dist/src/view/bood/crab.js +121 -0
- package/dist/src/view/bood/index.d.ts +29 -0
- package/dist/src/view/bood/index.js +25 -0
- package/dist/src/view/bood/motion.d.ts +178 -0
- package/dist/src/view/bood/motion.js +298 -0
- package/dist/src/view/bood/owl.d.ts +9 -0
- package/dist/src/view/bood/owl.js +285 -0
- package/dist/src/view/bood/registry.d.ts +36 -0
- package/dist/src/view/bood/registry.js +218 -0
- package/dist/src/view/bood/render.d.ts +31 -0
- package/dist/src/view/bood/render.js +196 -0
- package/dist/src/view/bood/sprite.d.ts +18 -0
- package/dist/src/view/bood/sprite.js +130 -0
- package/dist/src/view/bood/sprout.d.ts +8 -0
- package/dist/src/view/bood/sprout.js +121 -0
- package/dist/src/view/bood/types.d.ts +158 -0
- package/dist/src/view/bood/types.js +57 -0
- package/dist/src/view/bubble.d.ts +75 -0
- package/dist/src/view/bubble.js +86 -0
- package/dist/src/view/changes.d.ts +35 -0
- package/dist/src/view/changes.js +22 -0
- package/dist/src/view/composer.d.ts +64 -0
- package/dist/src/view/composer.js +192 -0
- package/dist/src/view/controls.d.ts +44 -0
- package/dist/src/view/controls.js +49 -0
- package/dist/src/view/creature.d.ts +69 -0
- package/dist/src/view/creature.js +83 -0
- package/dist/src/view/customizations.d.ts +28 -0
- package/dist/src/view/customizations.js +53 -0
- package/dist/src/view/details.d.ts +65 -0
- package/dist/src/view/details.js +65 -0
- package/dist/src/view/filediff.d.ts +29 -0
- package/dist/src/view/filediff.js +24 -0
- package/dist/src/view/files.d.ts +31 -0
- package/dist/src/view/files.js +39 -0
- package/dist/src/view/hitl.d.ts +43 -0
- package/dist/src/view/hitl.js +171 -0
- package/dist/src/view/icons.d.ts +13 -0
- package/dist/src/view/icons.js +71 -0
- package/dist/src/view/picker.d.ts +42 -0
- package/dist/src/view/picker.js +71 -0
- package/dist/src/view/sessionhead.d.ts +41 -0
- package/dist/src/view/sessionhead.js +60 -0
- package/dist/src/view/sessions.d.ts +34 -0
- package/dist/src/view/sessions.js +61 -0
- package/dist/src/view/terminal.d.ts +30 -0
- package/dist/src/view/terminal.js +32 -0
- package/dist/src/view/toolcall.d.ts +27 -0
- package/dist/src/view/toolcall.js +48 -0
- package/dist/src/view/transcript.d.ts +50 -0
- package/dist/src/view/transcript.js +60 -0
- package/package.json +72 -0
|
@@ -0,0 +1,1996 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { SessionFlag } from './types.js';
|
|
3
|
+
const WORDS = (text) => text.split(/(?<=\s)/);
|
|
4
|
+
/** What the host says this provider's sessions can be told to do. */
|
|
5
|
+
/**
|
|
6
|
+
* The five thinking levels, in the reference client's words.
|
|
7
|
+
*
|
|
8
|
+
* Held in one place because they are one host's list rather than a protocol
|
|
9
|
+
* vocabulary: ahpd and VS Code agree on these five spellings and a third
|
|
10
|
+
* implementation need not, which is why every reader takes labels from the
|
|
11
|
+
* host positionally instead of keeping a table like this of its own.
|
|
12
|
+
*/
|
|
13
|
+
const EFFORTS = [
|
|
14
|
+
{ value: 'low', label: 'Low' },
|
|
15
|
+
{ value: 'medium', label: 'Medium' },
|
|
16
|
+
{ value: 'high', label: 'High' },
|
|
17
|
+
{ value: 'xhigh', label: 'Extra High' },
|
|
18
|
+
{ value: 'max', label: 'Max' },
|
|
19
|
+
];
|
|
20
|
+
const CONFIG = [
|
|
21
|
+
{
|
|
22
|
+
key: 'branch',
|
|
23
|
+
title: 'Branch',
|
|
24
|
+
description: 'What to base a worktree on.',
|
|
25
|
+
// The host saying "ask me": a branch list is a query, not a schema. The
|
|
26
|
+
// reference host marks exactly this property this way.
|
|
27
|
+
values: [],
|
|
28
|
+
enumDynamic: true,
|
|
29
|
+
sessionMutable: false,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
key: 'permissionMode',
|
|
33
|
+
title: 'Permissions',
|
|
34
|
+
description: 'How much the agent may do before it asks.',
|
|
35
|
+
sessionMutable: true,
|
|
36
|
+
values: [
|
|
37
|
+
{ value: 'default', label: 'Ask each time', description: 'Every tool call is confirmed' },
|
|
38
|
+
{ value: 'acceptEdits', label: 'Accept edits', description: 'File edits run; commands still ask' },
|
|
39
|
+
{ value: 'plan', label: 'Plan only', description: 'Read and reason, change nothing' },
|
|
40
|
+
{ value: 'bypass', label: 'Bypass', description: 'Nothing is confirmed' },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: 'isolation',
|
|
45
|
+
title: 'Isolation',
|
|
46
|
+
description: 'Where the agent works. Fixed once the session exists.',
|
|
47
|
+
sessionMutable: false,
|
|
48
|
+
values: [
|
|
49
|
+
{ value: 'workspace', label: 'Workspace', description: 'Change the directory in place' },
|
|
50
|
+
{ value: 'worktree', label: 'Worktree', description: 'Change a git worktree of it' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
let counter = 0;
|
|
55
|
+
const nextId = (prefix) => `${prefix}${++counter}`;
|
|
56
|
+
const AT = '2026-08-22T10:00:00.000Z';
|
|
57
|
+
/** What the scripted shell answers. Anything else is not found, as a shell says. */
|
|
58
|
+
const SHELL = {
|
|
59
|
+
pwd: '/brb_main/src/brb_framework\n',
|
|
60
|
+
ls: 'Makefile Makefile.linux libbrb_core compileLinux.sh\n',
|
|
61
|
+
whoami: 'softov\n',
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* The scripted filesystem `@` completes against.
|
|
65
|
+
*
|
|
66
|
+
* Keyed by the directory as it is typed, values ending in `/` being
|
|
67
|
+
* directories - which is what keeps the next keystroke inside one rather than
|
|
68
|
+
* starting again.
|
|
69
|
+
*/
|
|
70
|
+
const FILES = {
|
|
71
|
+
'': ['src/', 'test/', 'README.md', 'package.json'],
|
|
72
|
+
'src/': ['app.tsx', 'control.ts', 'state.ts', 'ahp/'],
|
|
73
|
+
'src/ahp/': ['fake.ts', 'live.ts', 'types.ts'],
|
|
74
|
+
'test/': ['smoke.test.tsx'],
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* What each of those files says, for the half of the client that reads them.
|
|
78
|
+
*
|
|
79
|
+
* Every leaf in `FILES` has one. A tree that lists nine files and can open two
|
|
80
|
+
* is a fixture that makes a viewer look broken, and the completion menu and the
|
|
81
|
+
* file reader have to agree about what exists or one of them is testing a
|
|
82
|
+
* different host.
|
|
83
|
+
*/
|
|
84
|
+
const SOURCES = {
|
|
85
|
+
'README.md': '# ahpc\n\nA terminal client for the Agent Host Protocol.\n\nIt talks to any AHP host. It depends on no agent SDK.\n',
|
|
86
|
+
'package.json': '{\n "name": "ahpc",\n "type": "module",\n "bin": { "ahpc": "./dist/src/main.js" }\n}\n',
|
|
87
|
+
'src/app.tsx': "import { fakeHost } from './ahp/fake.js';\n\n// The screens, and what drives them.\nexport function App() {\n return null;\n}\n",
|
|
88
|
+
'src/control.ts': '// Keys in, intent out. Nothing here draws anything.\nexport type Intent = { kind: string };\n',
|
|
89
|
+
'src/state.ts': '// What is on screen, as one object nothing else may write to.\nexport interface State { screen: string }\n',
|
|
90
|
+
'src/ahp/fake.ts': '// A scripted host, so the client runs with nothing installed.\nexport function fakeHost() { return {}; }\n',
|
|
91
|
+
'src/ahp/live.ts': '// The same seam over a WebSocket.\nexport function liveHost(url: string) { return { url }; }\n',
|
|
92
|
+
'src/ahp/types.ts': '// The shapes both hosts speak in.\nexport interface Turn { id: string }\n',
|
|
93
|
+
'test/smoke.test.tsx': "import { it } from 'vitest';\n\nit('draws something', () => {});\n",
|
|
94
|
+
};
|
|
95
|
+
/** How many held-back turns the scripted host hands over at a time. */
|
|
96
|
+
const PAGE = 10;
|
|
97
|
+
export function fakeHost() {
|
|
98
|
+
const summaries = new Map();
|
|
99
|
+
const turns = new Map();
|
|
100
|
+
/**
|
|
101
|
+
* Turns this host has but has not handed over, oldest last.
|
|
102
|
+
*
|
|
103
|
+
* A real host sends a tail window and a cursor for the rest, so a client
|
|
104
|
+
* that never asks sees a conversation that starts partway through. Holding
|
|
105
|
+
* some back here is what lets that be tested against the scripted host
|
|
106
|
+
* rather than only against a daemon.
|
|
107
|
+
*/
|
|
108
|
+
const older = new Map();
|
|
109
|
+
const active = new Map();
|
|
110
|
+
const inputs = new Map();
|
|
111
|
+
const changesets = new Map();
|
|
112
|
+
/**
|
|
113
|
+
* What is waiting for the running turn to end.
|
|
114
|
+
*
|
|
115
|
+
* The host's, because on a real one it is: `queuedMessages` is on the chat
|
|
116
|
+
* and the server starts the next turn from the head. The fake has to drain
|
|
117
|
+
* it for the same reason, or a queue is a list that only ever grows.
|
|
118
|
+
*/
|
|
119
|
+
const queues = new Map();
|
|
120
|
+
/** What each chat is holding as a draft, which is host state and not a screen's. */
|
|
121
|
+
const drafts = new Map();
|
|
122
|
+
/** Paths something is watching, so a test can see one released. */
|
|
123
|
+
const watched = new Set();
|
|
124
|
+
/** Tokens pushed, by resource. An empty token revokes, as the protocol says. */
|
|
125
|
+
const tokens = new Map();
|
|
126
|
+
const configs = new Map();
|
|
127
|
+
const observers = new Map();
|
|
128
|
+
const chats = new Map();
|
|
129
|
+
/**
|
|
130
|
+
* Chats opened after the session was, by chat URI.
|
|
131
|
+
*
|
|
132
|
+
* The session's own maps above are the *default* chat's - that is what
|
|
133
|
+
* every script sets up, and a session that has only ever had one is a
|
|
134
|
+
* session where the two are the same thing. A second chat is its own
|
|
135
|
+
* conversation with nothing in it, which is what a second chat is.
|
|
136
|
+
*/
|
|
137
|
+
const extra = new Map();
|
|
138
|
+
/** The scripted shells, by terminal URI. */
|
|
139
|
+
const shells = new Map();
|
|
140
|
+
const shellState = (uri, held) => ({
|
|
141
|
+
title: held.title,
|
|
142
|
+
output: held.output,
|
|
143
|
+
cwd: held.cwd,
|
|
144
|
+
...(held.exitCode !== undefined ? { exitCode: held.exitCode } : {}),
|
|
145
|
+
// Pipes, like the daemon's. Said rather than left to be discovered by
|
|
146
|
+
// rendering something that draws itself with cursor movement.
|
|
147
|
+
isPty: false,
|
|
148
|
+
});
|
|
149
|
+
const models = new Map();
|
|
150
|
+
/** `IsRead` and `IsArchived` only. Nothing about what the session is doing. */
|
|
151
|
+
const flags = new Map();
|
|
152
|
+
const failed = new Set();
|
|
153
|
+
/** What a `ContentRef` points at. Keyed by the ref's own uri. */
|
|
154
|
+
const contents = new Map();
|
|
155
|
+
const script = [];
|
|
156
|
+
const emit = (uri, event) => {
|
|
157
|
+
for (const observer of observers.get(uri) ?? [])
|
|
158
|
+
observer(event);
|
|
159
|
+
};
|
|
160
|
+
/** A session's chats: its own, and any opened since. */
|
|
161
|
+
const chatsOf = (uri) => {
|
|
162
|
+
const own = chats.get(uri);
|
|
163
|
+
return [
|
|
164
|
+
...(own ? [{ resource: own, title: summaries.get(uri)?.title ?? 'Chat' }] : []),
|
|
165
|
+
...[...extra].filter(([, held]) => held.session === uri).map(([resource, held]) => ({ resource, title: held.title })),
|
|
166
|
+
];
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* A file, and where its two versions are.
|
|
170
|
+
*
|
|
171
|
+
* The rows carry pointers and the text goes in `contents`, which is the
|
|
172
|
+
* protocol's own arrangement rather than a convenience here: a changeset is
|
|
173
|
+
* a list a client wants up front and a pile of bytes it wants only for the
|
|
174
|
+
* row somebody opened. Building the fake the same way is what makes the
|
|
175
|
+
* fetch path something a test exercises rather than something only a real
|
|
176
|
+
* host ever takes.
|
|
177
|
+
*/
|
|
178
|
+
const edited = (uri, before, after) => {
|
|
179
|
+
const key = (side) => `ahp-content:/${uri.split('/').pop() ?? 'f'}-${side}`;
|
|
180
|
+
if (before !== null)
|
|
181
|
+
contents.set(key('before'), before);
|
|
182
|
+
if (after !== null)
|
|
183
|
+
contents.set(key('after'), after);
|
|
184
|
+
const lines = (text) => (text === null ? 0 : text.split('\n').length);
|
|
185
|
+
return {
|
|
186
|
+
uri,
|
|
187
|
+
...(before !== null ? { before: uri } : {}),
|
|
188
|
+
...(after !== null ? { after: uri } : {}),
|
|
189
|
+
diff: { added: lines(after), removed: lines(before) },
|
|
190
|
+
content: {
|
|
191
|
+
...(before !== null ? { before: { uri: key('before'), contentType: 'text/plain' } } : {}),
|
|
192
|
+
...(after !== null ? { after: { uri: key('after'), contentType: 'text/plain' } } : {}),
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* The changesets one session offers, keyed by the scope segment.
|
|
198
|
+
*
|
|
199
|
+
* Scoped rather than one per session, because on a real host a session
|
|
200
|
+
* advertises several and they differ - what this conversation changed is not
|
|
201
|
+
* what the working tree has, and a fake that answered the same list for every
|
|
202
|
+
* scope would let a picker be built that looked right and proved nothing.
|
|
203
|
+
*/
|
|
204
|
+
const scoped = new Map();
|
|
205
|
+
const EDITS = {
|
|
206
|
+
status: 'complete',
|
|
207
|
+
files: [
|
|
208
|
+
edited('file:///brb_main/src/brb_backend/compileLinux.sh', '#!/bin/sh\nset -e\nfor lib in libbrb_core libbrb_ev_kq; do\n make -C "$lib" -f Makefile\ndone\n', '#!/bin/sh\nset -e\nfor lib in libbrb_core libbrb_ev_kq libbrb_data; do\n make -C "$lib" -f Makefile.linux\ndone\n'),
|
|
209
|
+
// A creation: no `before` at all, which is the case a viewer that
|
|
210
|
+
// assumes two sides renders as a diff against the empty string and
|
|
211
|
+
// labels wrongly.
|
|
212
|
+
edited('file:///brb_main/src/brb_backend/README.linux.md', null, '# Building on Linux\n\nNeeds libkqueue built from source with\n`-DCMAKE_INSTALL_PREFIX=/usr`.\n'),
|
|
213
|
+
// And a deletion, for the same reason in the other direction.
|
|
214
|
+
edited('file:///brb_main/src/brb_backend/build.old.sh', 'make all\n', null),
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* The status, from what is actually here.
|
|
219
|
+
*
|
|
220
|
+
* Activity is a fact about this host's own maps - a pending input, a running
|
|
221
|
+
* turn, a failure - and the two client flags are carried alongside it. There
|
|
222
|
+
* is no way to write a status by hand, which is the point: a seeded session
|
|
223
|
+
* once claimed `InputNeeded` while holding no pending input, and opening it
|
|
224
|
+
* showed a conversation with nothing to answer. That is indistinguishable
|
|
225
|
+
* from a client that loses the request, and it cost an afternoon.
|
|
226
|
+
*/
|
|
227
|
+
const statusOf = (uri) => {
|
|
228
|
+
const activity = inputs.has(uri) ? SessionFlag.InputNeeded
|
|
229
|
+
: active.has(uri) ? SessionFlag.InProgress
|
|
230
|
+
: failed.has(uri) ? SessionFlag.Error
|
|
231
|
+
: SessionFlag.Idle;
|
|
232
|
+
return activity | (flags.get(uri) ?? 0);
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Watchers of the catalogue itself, as opposed to of one session.
|
|
236
|
+
*
|
|
237
|
+
* A real host says this on its root channel; here it is said by whatever
|
|
238
|
+
* changed a summary, which is the same thing from the outside.
|
|
239
|
+
*/
|
|
240
|
+
/**
|
|
241
|
+
* Every action a client has sent through `dispatch`, in order.
|
|
242
|
+
*
|
|
243
|
+
* Kept because the escape hatch is the one method whose whole job is to
|
|
244
|
+
* carry things this fake does not understand: there is nothing to observe
|
|
245
|
+
* for most of them, so without a record there is nothing to assert either.
|
|
246
|
+
*/
|
|
247
|
+
const sent = [];
|
|
248
|
+
/** Resources this client has been granted write on, as a real host keeps them. */
|
|
249
|
+
const granted = new Set();
|
|
250
|
+
/** Every operation actually run, so a test can assert the gate was passed rather than skipped. */
|
|
251
|
+
const invoked = [];
|
|
252
|
+
/**
|
|
253
|
+
* A changeset URI split back into the session and the scope.
|
|
254
|
+
*
|
|
255
|
+
* `<sessionUri>/changeset/<scope>`, which is the protocol's own nesting -
|
|
256
|
+
* and the reason a scope may itself contain slashes, so only the first
|
|
257
|
+
* separator is the one that matters.
|
|
258
|
+
*/
|
|
259
|
+
const scopeIn = (uri) => {
|
|
260
|
+
const cut = uri.indexOf('/changeset/');
|
|
261
|
+
if (cut <= 0)
|
|
262
|
+
return undefined;
|
|
263
|
+
return { owner: uri.slice(0, cut), scope: uri.slice(cut + '/changeset/'.length) };
|
|
264
|
+
};
|
|
265
|
+
/**
|
|
266
|
+
* The directories this host serves, as paths, longest first.
|
|
267
|
+
*
|
|
268
|
+
* Longest first so the deepest one wins: two served directories where one
|
|
269
|
+
* contains the other would otherwise resolve every file under the inner one
|
|
270
|
+
* against the outer, and list the wrong tree.
|
|
271
|
+
*/
|
|
272
|
+
const roots = () => [...new Set([...summaries.values()]
|
|
273
|
+
.flatMap((one) => one.workingDirectories)
|
|
274
|
+
.map((one) => one.replace(/^file:\/\//, '')))]
|
|
275
|
+
.sort((a, b) => b.length - a.length);
|
|
276
|
+
/**
|
|
277
|
+
* A `file://` URI, as a root and a path relative to it.
|
|
278
|
+
*
|
|
279
|
+
* `inside` is spelled the way `FILES` and `SOURCES` key themselves - a
|
|
280
|
+
* directory ends in a slash and the root itself is the empty string - so a
|
|
281
|
+
* URI can be looked up in either without a second convention.
|
|
282
|
+
*/
|
|
283
|
+
const inTree = (uri) => {
|
|
284
|
+
if (!uri.startsWith('file://'))
|
|
285
|
+
return undefined;
|
|
286
|
+
const path = uri.slice('file://'.length);
|
|
287
|
+
const root = roots().find((one) => path === one || path.startsWith(`${one}/`));
|
|
288
|
+
if (root === undefined)
|
|
289
|
+
return undefined;
|
|
290
|
+
const rest = path.slice(root.length).replace(/^\//, '');
|
|
291
|
+
if (rest === '')
|
|
292
|
+
return { root, inside: '' };
|
|
293
|
+
return { root, inside: FILES[`${rest}/`] === undefined ? rest : `${rest}/` };
|
|
294
|
+
};
|
|
295
|
+
const catalogue = new Set();
|
|
296
|
+
const moved = () => { for (const listener of catalogue)
|
|
297
|
+
listener(); };
|
|
298
|
+
/** Recompute, and tell anyone watching if it moved. */
|
|
299
|
+
const touch = (uri) => {
|
|
300
|
+
const summary = summaries.get(uri);
|
|
301
|
+
if (!summary)
|
|
302
|
+
return;
|
|
303
|
+
const status = statusOf(uri);
|
|
304
|
+
summaries.set(uri, { ...summary, status, modifiedAt: AT });
|
|
305
|
+
emit(uri, { type: 'status', status });
|
|
306
|
+
moved();
|
|
307
|
+
};
|
|
308
|
+
const setFlag = (uri, flag, on) => {
|
|
309
|
+
const current = flags.get(uri) ?? 0;
|
|
310
|
+
flags.set(uri, on ? current | flag : current & ~flag);
|
|
311
|
+
touch(uri);
|
|
312
|
+
};
|
|
313
|
+
// ----------------------------------------------------------- the automations
|
|
314
|
+
/**
|
|
315
|
+
* Automations this host holds, and who is watching them.
|
|
316
|
+
*
|
|
317
|
+
* Two, because the interesting screen is the one with both kinds on it: one
|
|
318
|
+
* on a clock with a history behind it, and one that is only ever run by
|
|
319
|
+
* hand. A fixture with a single scheduled automation would let a screen be
|
|
320
|
+
* built that assumes every automation has a next run.
|
|
321
|
+
*/
|
|
322
|
+
const automations = new Map([
|
|
323
|
+
['ahp-automation:/9c4a', {
|
|
324
|
+
resource: 'ahp-automation:/9c4a',
|
|
325
|
+
title: 'Nightly framework build',
|
|
326
|
+
enabled: true,
|
|
327
|
+
schedule: { expression: '0 9 * * 1-5', timeZone: 'America/Sao_Paulo' },
|
|
328
|
+
// Relative to now, so the fixture is still a *next* run tomorrow and
|
|
329
|
+
// next year. A hardcoded date becomes a schedule in the past, which is
|
|
330
|
+
// the one thing a next run cannot be.
|
|
331
|
+
nextRunAt: new Date(Date.now() + 4 * 3_600_000 + 12 * 60_000).toISOString(),
|
|
332
|
+
runs: [
|
|
333
|
+
{ resource: 'ahp-automation-run:/r3', status: 'completed', session: 'ahp-session:/1f0a', triggered: true },
|
|
334
|
+
{ resource: 'ahp-automation-run:/r2', status: 'completed', session: 'ahp-session:/6b21', triggered: true },
|
|
335
|
+
{ resource: 'ahp-automation-run:/r1', status: 'failed', triggered: true },
|
|
336
|
+
],
|
|
337
|
+
operations: ['update', 'remove', 'run'],
|
|
338
|
+
}],
|
|
339
|
+
['ahp-automation:/2e71', {
|
|
340
|
+
resource: 'ahp-automation:/2e71',
|
|
341
|
+
title: 'Triage new Desk cases',
|
|
342
|
+
// Switched off, which is why it has no next run despite the host having
|
|
343
|
+
// a clock. The absent `nextRunAt` means the same thing for both.
|
|
344
|
+
enabled: false,
|
|
345
|
+
schedule: { expression: '*/30 * * * *', timeZone: 'UTC' },
|
|
346
|
+
runs: [],
|
|
347
|
+
// No `run` while it is off: offering the button anyway would be a
|
|
348
|
+
// control that argues with the switch beside it.
|
|
349
|
+
operations: ['update', 'remove'],
|
|
350
|
+
}],
|
|
351
|
+
]);
|
|
352
|
+
const automationWatchers = new Set();
|
|
353
|
+
const automationsMoved = () => { for (const listener of automationWatchers)
|
|
354
|
+
listener(); };
|
|
355
|
+
// ------------------------------------------------------------ the catalogue
|
|
356
|
+
const seed = (options) => {
|
|
357
|
+
const { id } = options;
|
|
358
|
+
flags.set(id, (options.read === false ? 0 : SessionFlag.IsRead)
|
|
359
|
+
| (options.archived ? SessionFlag.IsArchived : 0));
|
|
360
|
+
if (options.failed)
|
|
361
|
+
failed.add(id);
|
|
362
|
+
turns.set(id, options.turns ?? []);
|
|
363
|
+
older.set(id, options.older ?? []);
|
|
364
|
+
if (options.active)
|
|
365
|
+
active.set(id, options.active);
|
|
366
|
+
if (options.input)
|
|
367
|
+
inputs.set(id, options.input);
|
|
368
|
+
if (options.changes) {
|
|
369
|
+
changesets.set(id, options.changes);
|
|
370
|
+
/*
|
|
371
|
+
* The same files, cut four ways.
|
|
372
|
+
*
|
|
373
|
+
* A real host answers a different list per scope, and the differences
|
|
374
|
+
* are the point of having scopes at all: the working tree holds work
|
|
375
|
+
* nobody's agent did, one turn holds one file, and a comparison holds
|
|
376
|
+
* the span between two. A fake that returned the session's list for
|
|
377
|
+
* every one of them would let a picker be built that switched between
|
|
378
|
+
* four identical screens.
|
|
379
|
+
*/
|
|
380
|
+
const spoken = (options.turns ?? []).filter((turn) => turn.role === 'agent').map((turn) => turn.id);
|
|
381
|
+
const files = options.changes.files;
|
|
382
|
+
/*
|
|
383
|
+
* The verbs, on the scope each actually belongs to.
|
|
384
|
+
*
|
|
385
|
+
* A real host advertises different ones per scope - the working tree can
|
|
386
|
+
* be committed and a turn cannot, and what a turn changed can be put
|
|
387
|
+
* back because both sides of it were captured. A fixture that offered
|
|
388
|
+
* the same three everywhere would let a screen be built that is wrong
|
|
389
|
+
* against every real host.
|
|
390
|
+
*/
|
|
391
|
+
const COMMIT = {
|
|
392
|
+
id: 'commit', label: 'Commit', scopes: ['changeset'], icon: 'git-commit', group: 'commit', status: 'idle',
|
|
393
|
+
};
|
|
394
|
+
const DISCARD = {
|
|
395
|
+
id: 'discard',
|
|
396
|
+
label: 'Discard Changes',
|
|
397
|
+
scopes: ['resource'],
|
|
398
|
+
confirmation: 'Discard the changes to this file? This cannot be undone.',
|
|
399
|
+
icon: 'discard',
|
|
400
|
+
status: 'idle',
|
|
401
|
+
};
|
|
402
|
+
const REVERT = {
|
|
403
|
+
id: 'revert',
|
|
404
|
+
label: 'Revert This File',
|
|
405
|
+
scopes: ['resource'],
|
|
406
|
+
confirmation: 'Put this file back the way the agent found it?',
|
|
407
|
+
icon: 'discard',
|
|
408
|
+
status: 'idle',
|
|
409
|
+
};
|
|
410
|
+
const per = new Map([
|
|
411
|
+
['session', { ...options.changes, operations: [REVERT] }],
|
|
412
|
+
['uncommitted', {
|
|
413
|
+
status: 'complete',
|
|
414
|
+
operations: [COMMIT, DISCARD],
|
|
415
|
+
files: [
|
|
416
|
+
...files,
|
|
417
|
+
// Somebody else's edit, sitting in the tree beside the agent's.
|
|
418
|
+
// What makes `uncommitted` worth a separate scope rather than a
|
|
419
|
+
// second name for `session`.
|
|
420
|
+
edited(`${options.dir}/notes.todo`, null, 'check the kqueue patch against 10.1\n'),
|
|
421
|
+
],
|
|
422
|
+
}],
|
|
423
|
+
]);
|
|
424
|
+
for (const id_ of spoken) {
|
|
425
|
+
const one = files[spoken.indexOf(id_) % files.length];
|
|
426
|
+
if (one)
|
|
427
|
+
per.set(`turn/${id_}`, { status: 'complete', files: [one], operations: [REVERT] });
|
|
428
|
+
}
|
|
429
|
+
const [first, second] = spoken;
|
|
430
|
+
if (first !== undefined && second !== undefined) {
|
|
431
|
+
per.set(`compare/${first}/${second}`, { status: 'complete', files: files.slice(0, 2) });
|
|
432
|
+
}
|
|
433
|
+
scoped.set(id, per);
|
|
434
|
+
}
|
|
435
|
+
// `ahp-chat:/<uuid>`, which is the protocol's own shape - a chat is its
|
|
436
|
+
// own channel, not a path under the session.
|
|
437
|
+
chats.set(id, `ahp-chat:/${id.split('/').pop() ?? id}`);
|
|
438
|
+
if (options.model)
|
|
439
|
+
models.set(id, options.model);
|
|
440
|
+
configs.set(id, {
|
|
441
|
+
permissionMode: options.permissions ?? 'default',
|
|
442
|
+
isolation: options.isolation ?? 'workspace',
|
|
443
|
+
});
|
|
444
|
+
summaries.set(id, {
|
|
445
|
+
resource: id,
|
|
446
|
+
provider: options.provider,
|
|
447
|
+
title: options.title,
|
|
448
|
+
status: statusOf(id),
|
|
449
|
+
createdAt: AT,
|
|
450
|
+
modifiedAt: AT,
|
|
451
|
+
workingDirectories: [options.dir],
|
|
452
|
+
/*
|
|
453
|
+
* What a host says about git, in the vocabulary the reference host uses.
|
|
454
|
+
*
|
|
455
|
+
* `_meta` is an open map and `git` is convention rather than
|
|
456
|
+
* specification, so the names here are copied from a capture rather than
|
|
457
|
+
* from a declaration - `branchName`, not `branch`. A fixture spelling it
|
|
458
|
+
* the other way is a fixture that agrees with a client reading it wrong,
|
|
459
|
+
* which is exactly what happened: the branch row said "the host does not
|
|
460
|
+
* say" against hosts that were saying it.
|
|
461
|
+
*/
|
|
462
|
+
_meta: {
|
|
463
|
+
git: {
|
|
464
|
+
branchName: options.branch ?? 'main',
|
|
465
|
+
upstreamBranchName: `origin/${options.branch ?? 'main'}`,
|
|
466
|
+
hasGitHubRemote: true,
|
|
467
|
+
incomingChanges: options.drift?.[1] ?? 0,
|
|
468
|
+
outgoingChanges: options.drift?.[0] ?? 0,
|
|
469
|
+
uncommittedChanges: options.drift?.[2] ?? 0,
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
...(options.activity ? { activity: options.activity } : {}),
|
|
473
|
+
...(options.origin ? { origin: options.origin } : {}),
|
|
474
|
+
...(options.changes
|
|
475
|
+
? {
|
|
476
|
+
changes: {
|
|
477
|
+
files: options.changes.files.length,
|
|
478
|
+
additions: options.changes.files.reduce((n, f) => n + f.diff.added, 0),
|
|
479
|
+
deletions: options.changes.files.reduce((n, f) => n + f.diff.removed, 0),
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
: {}),
|
|
483
|
+
});
|
|
484
|
+
};
|
|
485
|
+
/**
|
|
486
|
+
* One session blocked on a confirmation, and the confirmation to go with it.
|
|
487
|
+
*
|
|
488
|
+
* The whole point of the seed: a reader arriving at the catalogue can answer
|
|
489
|
+
* something without saying anything first, and the row that says a person is
|
|
490
|
+
* wanted is a row where one actually is.
|
|
491
|
+
*/
|
|
492
|
+
const blockedCall = {
|
|
493
|
+
id: 'seed-c2', name: 'Bash', toolName: 'Bash', status: 'pending-confirmation',
|
|
494
|
+
input: 'make -f Makefile.linux clean all',
|
|
495
|
+
intention: 'Rebuild libbrb_core against the patched libkqueue',
|
|
496
|
+
confirmationTitle: 'Run a command in /brb_main/src/brb_framework?',
|
|
497
|
+
options: [
|
|
498
|
+
{ id: 'once', label: 'Allow once' },
|
|
499
|
+
{ id: 'session', label: 'Allow for this session' },
|
|
500
|
+
],
|
|
501
|
+
};
|
|
502
|
+
seed({
|
|
503
|
+
id: 'ahp-session:/1f0a',
|
|
504
|
+
provider: 'claude',
|
|
505
|
+
title: 'Kqueue events on Linux',
|
|
506
|
+
dir: 'file:///brb_main/src/brb_framework',
|
|
507
|
+
model: 'claude-opus-5',
|
|
508
|
+
activity: 'waiting for permission to run a command',
|
|
509
|
+
turns: [
|
|
510
|
+
{
|
|
511
|
+
id: 't1', role: 'user', message: 'EVFILT_FS never fires on Linux. Is that us or libkqueue?',
|
|
512
|
+
parts: [], state: 'complete', at: AT,
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
id: 't2',
|
|
516
|
+
role: 'agent',
|
|
517
|
+
state: 'complete',
|
|
518
|
+
model: { id: 'claude-opus-5' },
|
|
519
|
+
at: AT,
|
|
520
|
+
elapsedMs: 21_400,
|
|
521
|
+
parts: [
|
|
522
|
+
{ kind: 'reasoning', id: 'r1', content: 'The filter is registered, so the question is whether libkqueue implements it at all.' },
|
|
523
|
+
{ kind: 'markdown', id: 'm1', content: 'Short answer: **libkqueue**, not us.\n\nBoth `EVFILT_AIO` and `EVFILT_FS` are compiled out:' },
|
|
524
|
+
{
|
|
525
|
+
kind: 'toolCall',
|
|
526
|
+
id: 'c1',
|
|
527
|
+
call: {
|
|
528
|
+
id: 'c1', name: 'Search', toolName: 'Grep', status: 'completed',
|
|
529
|
+
input: 'rg -n "EVFILT_FS" /usr/include/kqueue',
|
|
530
|
+
intention: 'Look for the filter in the installed headers',
|
|
531
|
+
outcome: 'Found 2 matches',
|
|
532
|
+
output: 'sys/event.h:74:#if 0\nsys/event.h:75:#define EVFILT_FS (-9)',
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
{ kind: 'markdown', id: 'm2', content: 'They sit behind `#if 0`. Re-defining them compiles and then silently never delivers an event, which is the worst of the three outcomes.\n\n- keep the FreeBSD path on `EVFILT_FS`\n- on Linux, poll or use `inotify` directly' },
|
|
536
|
+
],
|
|
537
|
+
},
|
|
538
|
+
],
|
|
539
|
+
active: {
|
|
540
|
+
id: 't3',
|
|
541
|
+
role: 'agent',
|
|
542
|
+
state: 'running',
|
|
543
|
+
model: { id: 'claude-opus-5', config: { thinkingLevel: 'medium' } },
|
|
544
|
+
at: AT,
|
|
545
|
+
parts: [
|
|
546
|
+
{ kind: 'markdown', id: 'm3', content: 'Let me check that the patched header actually builds before you take it any further.' },
|
|
547
|
+
{ kind: 'toolCall', id: blockedCall.id, call: blockedCall },
|
|
548
|
+
],
|
|
549
|
+
},
|
|
550
|
+
input: { kind: 'toolConfirmation', id: 'seed-i1', call: blockedCall },
|
|
551
|
+
});
|
|
552
|
+
seed({
|
|
553
|
+
id: 'ahp-session:/6b21',
|
|
554
|
+
provider: 'claude',
|
|
555
|
+
title: 'Split the transcript viewport',
|
|
556
|
+
dir: 'file:///github/textui',
|
|
557
|
+
model: 'claude-sonnet-5',
|
|
558
|
+
activity: 'reading packages/core/src/ui/data.ts',
|
|
559
|
+
permissions: 'acceptEdits',
|
|
560
|
+
turns: [
|
|
561
|
+
{
|
|
562
|
+
id: 's2-t1', role: 'user', message: 'The Feed draws nothing inside a content-sized panel. Why?',
|
|
563
|
+
parts: [], state: 'complete', at: AT,
|
|
564
|
+
},
|
|
565
|
+
],
|
|
566
|
+
// Mid-flight, and nothing is blocked. A reader arriving here sees a turn
|
|
567
|
+
// being written, which is the state a transcript is hardest to get right in.
|
|
568
|
+
active: {
|
|
569
|
+
id: 's2-t2',
|
|
570
|
+
role: 'agent',
|
|
571
|
+
state: 'running',
|
|
572
|
+
model: { id: 'claude-sonnet-5' },
|
|
573
|
+
at: AT,
|
|
574
|
+
parts: [
|
|
575
|
+
{ kind: 'reasoning', id: 's2-r1', content: 'Sizing rule first: a component that fills has a measured height, one that does not draws everything.' },
|
|
576
|
+
{ kind: 'markdown', id: 's2-m1', content: 'Because it takes its viewport from `useMeasure`, and in a content-sized panel there is nothing to measure - the height is zero, the window is zero rows, and it' },
|
|
577
|
+
],
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
seed({
|
|
581
|
+
id: 'ahp-session:/9c74',
|
|
582
|
+
provider: 'copilotcli',
|
|
583
|
+
title: 'Why does the composer eat q',
|
|
584
|
+
dir: 'file:///github/textui',
|
|
585
|
+
model: 'gpt-5',
|
|
586
|
+
turns: [
|
|
587
|
+
{
|
|
588
|
+
id: 's3-t1', role: 'user', message: 'q does nothing while I am typing. Bug?',
|
|
589
|
+
parts: [], state: 'complete', at: AT,
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
id: 's3-t2',
|
|
593
|
+
role: 'agent',
|
|
594
|
+
state: 'complete',
|
|
595
|
+
model: { id: 'gpt-5' },
|
|
596
|
+
at: AT,
|
|
597
|
+
elapsedMs: 3_100,
|
|
598
|
+
parts: [
|
|
599
|
+
{ kind: 'markdown', id: 's3-m1', content: 'No - that is the focus model working. The focused node is offered a key before any keybinding, so while the composer has it, `q` is a letter.\n\nBind single letters to a focus scope and they exist only where they mean something.' },
|
|
600
|
+
],
|
|
601
|
+
},
|
|
602
|
+
],
|
|
603
|
+
});
|
|
604
|
+
seed({
|
|
605
|
+
id: 'ahp-session:/2d55',
|
|
606
|
+
provider: 'claude',
|
|
607
|
+
title: 'Advisor case 412 triage',
|
|
608
|
+
dir: 'file:///brb_main/src/service_advisor',
|
|
609
|
+
model: 'claude-opus-5',
|
|
610
|
+
read: false,
|
|
611
|
+
failed: true,
|
|
612
|
+
permissions: 'plan',
|
|
613
|
+
turns: [
|
|
614
|
+
{
|
|
615
|
+
id: 's4-t1', role: 'user', message: 'Pull the Desk ticket behind case 412 and summarise it.',
|
|
616
|
+
parts: [], state: 'complete', at: AT,
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
id: 's4-t2',
|
|
620
|
+
role: 'agent',
|
|
621
|
+
state: 'failed',
|
|
622
|
+
model: { id: 'claude-opus-5' },
|
|
623
|
+
at: AT,
|
|
624
|
+
elapsedMs: 900,
|
|
625
|
+
parts: [
|
|
626
|
+
{ kind: 'systemNotification', id: 's4-n1', content: 'The session ended: the host refused the request.' },
|
|
627
|
+
{ kind: 'markdown', id: 's4-m1', content: 'The host answered `-32007 Authentication is required to use Claude`. That is a sign-in on the host, not a network problem here.' },
|
|
628
|
+
// How a 0.9.0 host ends a turn it could not finish. A `failed` turn
|
|
629
|
+
// carrying no such part is the shape every earlier version had, so
|
|
630
|
+
// the one scripted failure here has one.
|
|
631
|
+
{ kind: 'error', id: 's4-e1', message: 'Sign in on the host, then run this turn again.', resumable: true },
|
|
632
|
+
],
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
});
|
|
636
|
+
seed({
|
|
637
|
+
id: 'ahp-session:/4e18',
|
|
638
|
+
provider: 'claude',
|
|
639
|
+
title: 'Old build script cleanup',
|
|
640
|
+
dir: 'file:///brb_main/src/brb_backend',
|
|
641
|
+
model: 'claude-sonnet-5',
|
|
642
|
+
archived: true,
|
|
643
|
+
turns: [
|
|
644
|
+
{
|
|
645
|
+
id: 's5-t1', role: 'user', message: 'Delete compileFramework.sh from the Linux path.',
|
|
646
|
+
parts: [], state: 'complete', at: AT,
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
id: 's5-t2',
|
|
650
|
+
role: 'agent',
|
|
651
|
+
state: 'complete',
|
|
652
|
+
model: { id: 'claude-sonnet-5' },
|
|
653
|
+
at: AT,
|
|
654
|
+
elapsedMs: 12_000,
|
|
655
|
+
parts: [
|
|
656
|
+
{ kind: 'markdown', id: 's5-m1', content: 'Left it in place. It is FreeBSD-only - `/usr/local/bin/bash` and `md5 -q` - so nothing on Linux calls it and removing it costs the FreeBSD build.' },
|
|
657
|
+
],
|
|
658
|
+
},
|
|
659
|
+
],
|
|
660
|
+
changes: EDITS,
|
|
661
|
+
});
|
|
662
|
+
// ----------------------------------------------------- what the host handed it
|
|
663
|
+
/**
|
|
664
|
+
* The skills, the servers and where they came from.
|
|
665
|
+
*
|
|
666
|
+
* Not per-session here, though the protocol's is: the same list answers for
|
|
667
|
+
* every session, because what this is for is having something on screen with
|
|
668
|
+
* nothing installed. What it does reproduce is the *shape* - two containers
|
|
669
|
+
* with children, one MCP server contributed at the top level by the host
|
|
670
|
+
* itself, one plugin that failed to load, one skill the agent may use and a
|
|
671
|
+
* person may not, and a server waiting to be signed into.
|
|
672
|
+
*/
|
|
673
|
+
const CUSTOMIZATIONS = [
|
|
674
|
+
{
|
|
675
|
+
id: 'c-plug-review', kind: 'plugin', name: 'code-review',
|
|
676
|
+
uri: 'https://plugins.example/code-review', enabled: true,
|
|
677
|
+
description: 'A review pass, and the checklist it runs',
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
id: 'c-skill-review', kind: 'skill', name: 'review', from: 'code-review',
|
|
681
|
+
uri: 'file:///home/softov/.claude/plugins/code-review/skills/review/SKILL.md',
|
|
682
|
+
enabled: true, userInvocable: true,
|
|
683
|
+
description: 'Read the diff and report what is wrong with it',
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
id: 'c-skill-verify', kind: 'skill', name: 'verify', from: 'code-review',
|
|
687
|
+
uri: 'file:///home/softov/.claude/plugins/code-review/skills/verify/SKILL.md',
|
|
688
|
+
// The agent's, not a person's: offering it in a slash menu offers
|
|
689
|
+
// something the host would refuse.
|
|
690
|
+
enabled: true, userInvocable: false,
|
|
691
|
+
description: 'Try to refute a finding before it is reported',
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
id: 'c-mcp-desk', kind: 'mcpServer', name: 'desk', from: 'code-review',
|
|
695
|
+
uri: 'https://plugins.example/code-review#mcpServers.desk',
|
|
696
|
+
enabled: true, state: 'ready',
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
id: 'c-dir-commands', kind: 'directory', name: '.claude/commands',
|
|
700
|
+
uri: 'file:///brb_main/src/brb_framework/.claude/commands', enabled: true,
|
|
701
|
+
description: 'Slash commands for this workspace',
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
id: 'c-skill-linux', kind: 'skill', name: 'linux-build', from: '.claude/commands',
|
|
705
|
+
uri: 'file:///brb_main/src/brb_framework/.claude/commands/linux-build.md',
|
|
706
|
+
enabled: true, userInvocable: true,
|
|
707
|
+
description: 'Build the framework with the GNU makefiles',
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
id: 'c-prompt-release', kind: 'prompt', name: 'release-notes', from: '.claude/commands',
|
|
711
|
+
uri: 'file:///brb_main/src/brb_framework/.claude/commands/release-notes.md',
|
|
712
|
+
enabled: true, description: 'Write the notes for what is on this branch',
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
id: 'c-skill-off', kind: 'skill', name: 'deploy', from: '.claude/commands',
|
|
716
|
+
uri: 'file:///brb_main/src/brb_framework/.claude/commands/deploy.md',
|
|
717
|
+
// Off on its own, inside a directory that is on. The pair is what makes
|
|
718
|
+
// "enabled is derived from both" something a screen can be checked on.
|
|
719
|
+
enabled: false, userInvocable: true,
|
|
720
|
+
description: 'Push to buildbox and restart the service',
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
id: 'c-mcp-tasker', kind: 'mcpServer', name: 'tasker',
|
|
724
|
+
uri: 'file:///home/softov/.mcp.json#tasker', enabled: true, state: 'ready',
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
id: 'c-mcp-drive', kind: 'mcpServer', name: 'google-drive',
|
|
728
|
+
uri: 'file:///home/softov/.mcp.json#google-drive', enabled: true,
|
|
729
|
+
state: 'authRequired', problem: 'required',
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
id: 'c-plug-broken', kind: 'plugin', name: 'notes-sync',
|
|
733
|
+
uri: 'https://plugins.example/notes-sync', enabled: true,
|
|
734
|
+
problem: 'manifest is not valid JSON: unexpected } at line 14',
|
|
735
|
+
},
|
|
736
|
+
];
|
|
737
|
+
/** The harnesses this fixture advertises, and what each offers to run on. */
|
|
738
|
+
const AGENTS = [
|
|
739
|
+
{
|
|
740
|
+
provider: 'claude',
|
|
741
|
+
displayName: 'Claude Code',
|
|
742
|
+
description: 'Anthropic, in the editor',
|
|
743
|
+
// The scripted harness holds several chats, so the commands that need
|
|
744
|
+
// it are offered. A host that does not advertise this is one where
|
|
745
|
+
// `createChat` MUST NOT be called at all - and the second agent below
|
|
746
|
+
// deliberately does not, so the gate itself is scripted too.
|
|
747
|
+
multipleChats: true,
|
|
748
|
+
// Both, as the reference host advertises them.
|
|
749
|
+
chatSources: { fork: true, sideChat: true },
|
|
750
|
+
// What a real host advertises for a harness that needs signing in.
|
|
751
|
+
// `authenticate` may only name one of these.
|
|
752
|
+
protectedResources: [{ resource: 'https://api.anthropic.com', name: 'Anthropic API' }],
|
|
753
|
+
// Three shapes, because a real host sends three. A model that takes
|
|
754
|
+
// every thinking level, one that takes a single level that is not the
|
|
755
|
+
// one anything defaults to - so it carries no default at all, which is
|
|
756
|
+
// the case a form filling the gap in from the top of the list gets
|
|
757
|
+
// wrong - and one that takes none and carries no schema.
|
|
758
|
+
models: [
|
|
759
|
+
{
|
|
760
|
+
id: 'claude-opus-5',
|
|
761
|
+
displayName: 'Opus 5',
|
|
762
|
+
provider: 'claude',
|
|
763
|
+
options: [{
|
|
764
|
+
key: 'thinkingLevel',
|
|
765
|
+
title: 'Thinking Level',
|
|
766
|
+
description: 'Controls how much reasoning effort Claude uses.',
|
|
767
|
+
values: EFFORTS,
|
|
768
|
+
sessionMutable: true,
|
|
769
|
+
default: 'high',
|
|
770
|
+
}],
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
id: 'claude-sonnet-5',
|
|
774
|
+
displayName: 'Sonnet 5',
|
|
775
|
+
provider: 'claude',
|
|
776
|
+
options: [{
|
|
777
|
+
key: 'thinkingLevel',
|
|
778
|
+
title: 'Thinking Level',
|
|
779
|
+
description: 'Controls how much reasoning effort Claude uses.',
|
|
780
|
+
values: [EFFORTS[1]],
|
|
781
|
+
sessionMutable: true,
|
|
782
|
+
}],
|
|
783
|
+
},
|
|
784
|
+
{ id: 'claude-haiku-5', displayName: 'Haiku 5', provider: 'claude' },
|
|
785
|
+
],
|
|
786
|
+
// What this harness offers, before any session exists. The same list a
|
|
787
|
+
// session reports, which is what the protocol says it is: entries here
|
|
788
|
+
// are propagated into a session's own when one is created with this
|
|
789
|
+
// agent, so two different lists would be a fixture lying about the
|
|
790
|
+
// relationship it exists to demonstrate.
|
|
791
|
+
customizations: CUSTOMIZATIONS.map((entry) => ({ ...entry })),
|
|
792
|
+
},
|
|
793
|
+
// No models, on purpose. This is what a real host answers for a harness
|
|
794
|
+
// nobody has given it a token for: the harness is there, and it will
|
|
795
|
+
// enumerate nothing to run on until somebody signs in. A fixture where
|
|
796
|
+
// every harness has models is a client that has never been asked to say
|
|
797
|
+
// "none", and it says it by showing an empty panel forever.
|
|
798
|
+
// And no customizations either, for the same reason: a harness nobody
|
|
799
|
+
// has signed into enumerates neither.
|
|
800
|
+
{ provider: 'copilotcli', displayName: 'Copilot CLI', models: [] },
|
|
801
|
+
];
|
|
802
|
+
/** A model id, resolved the way a live host resolves one: against the catalogue. */
|
|
803
|
+
function modelRow(id) {
|
|
804
|
+
for (const agent of AGENTS) {
|
|
805
|
+
const found = agent.models.find((one) => one.id === id);
|
|
806
|
+
if (found)
|
|
807
|
+
return found;
|
|
808
|
+
}
|
|
809
|
+
return { id, displayName: id, provider: '' };
|
|
810
|
+
}
|
|
811
|
+
// ------------------------------------------------------------------ scripts
|
|
812
|
+
/** Stream one prose part into the running turn, a word per pump. */
|
|
813
|
+
function prose(uri, kind, text) {
|
|
814
|
+
const id = nextId(kind === 'markdown' ? 'm' : 'r');
|
|
815
|
+
script.push(() => {
|
|
816
|
+
const turn = active.get(uri);
|
|
817
|
+
if (turn)
|
|
818
|
+
turn.parts.push({ kind, id, content: '' });
|
|
819
|
+
});
|
|
820
|
+
for (const word of WORDS(text)) {
|
|
821
|
+
script.push(() => {
|
|
822
|
+
const turn = active.get(uri);
|
|
823
|
+
const found = turn?.parts.find((p) => p.id === id);
|
|
824
|
+
if (found && (found.kind === 'markdown' || found.kind === 'reasoning'))
|
|
825
|
+
found.content += word;
|
|
826
|
+
emit(uri, { type: 'delta', partId: id, kind, text: word });
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/** Add a tool call, then complete it a pump later. */
|
|
831
|
+
function tool(uri, call, done) {
|
|
832
|
+
const made = { ...call, id: nextId('c'), status: 'running' };
|
|
833
|
+
script.push(() => {
|
|
834
|
+
active.get(uri)?.parts.push({ kind: 'toolCall', id: made.id, call: made });
|
|
835
|
+
emit(uri, { type: 'toolCall', call: made });
|
|
836
|
+
});
|
|
837
|
+
script.push(() => {
|
|
838
|
+
made.status = done.status ?? 'completed';
|
|
839
|
+
Object.assign(made, done);
|
|
840
|
+
emit(uri, { type: 'toolCall', call: made });
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
/** Block on a confirmation. Nothing after this runs until it is answered. */
|
|
844
|
+
function asks(uri, call) {
|
|
845
|
+
const made = { ...call, id: nextId('c'), status: 'pending-confirmation' };
|
|
846
|
+
script.push(() => {
|
|
847
|
+
active.get(uri)?.parts.push({ kind: 'toolCall', id: made.id, call: made });
|
|
848
|
+
emit(uri, { type: 'toolCall', call: made });
|
|
849
|
+
const input = { kind: 'toolConfirmation', id: nextId('i'), call: made };
|
|
850
|
+
inputs.set(uri, input);
|
|
851
|
+
emit(uri, { type: 'inputNeeded', input });
|
|
852
|
+
touch(uri);
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
/** Block on a question, which is a different thing entirely. */
|
|
856
|
+
function elicits(uri, input) {
|
|
857
|
+
script.push(() => {
|
|
858
|
+
const made = { kind: 'chatInput', id: nextId('i'), ...input };
|
|
859
|
+
inputs.set(uri, made);
|
|
860
|
+
emit(uri, { type: 'inputNeeded', input: made });
|
|
861
|
+
touch(uri);
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Start the next queued turn, if one is waiting and nothing is running.
|
|
866
|
+
*
|
|
867
|
+
* One, not the lot. The queue is a list of turns to take in order, and a
|
|
868
|
+
* host that started them together would be interleaving turns in one chat -
|
|
869
|
+
* which is the thing queueing exists to prevent. Sending them as one joined
|
|
870
|
+
* message would be the other way to get it wrong: they were written as
|
|
871
|
+
* separate messages, and the agent reading them is entitled to see that.
|
|
872
|
+
*/
|
|
873
|
+
/**
|
|
874
|
+
* Cut the turn short.
|
|
875
|
+
*
|
|
876
|
+
* A function rather than only a method, because two callers want it: the
|
|
877
|
+
* control that stops a turn, and `chat/turnCancelled` arriving through
|
|
878
|
+
* `dispatch` - which is the same thing said the other way, and must not be
|
|
879
|
+
* a second, slightly different implementation of it.
|
|
880
|
+
*/
|
|
881
|
+
function stop(uri) {
|
|
882
|
+
script.length = 0;
|
|
883
|
+
const turn = active.get(uri);
|
|
884
|
+
if (turn) {
|
|
885
|
+
turn.state = 'cancelled';
|
|
886
|
+
turns.get(uri)?.push(turn);
|
|
887
|
+
active.delete(uri);
|
|
888
|
+
emit(uri, { type: 'turnComplete', turn });
|
|
889
|
+
}
|
|
890
|
+
inputs.delete(uri);
|
|
891
|
+
emit(uri, { type: 'inputResolved' });
|
|
892
|
+
touch(uri);
|
|
893
|
+
}
|
|
894
|
+
function drain(uri) {
|
|
895
|
+
if (active.has(uri))
|
|
896
|
+
return;
|
|
897
|
+
const waiting = queues.get(uri) ?? [];
|
|
898
|
+
const next = waiting[0];
|
|
899
|
+
if (!next)
|
|
900
|
+
return;
|
|
901
|
+
const rest = waiting.slice(1);
|
|
902
|
+
queues.set(uri, rest);
|
|
903
|
+
emit(uri, { type: 'queued', messages: rest });
|
|
904
|
+
reply(uri, next.text);
|
|
905
|
+
}
|
|
906
|
+
function finish(uri, closing, options = {}) {
|
|
907
|
+
if (closing)
|
|
908
|
+
prose(uri, 'markdown', closing);
|
|
909
|
+
script.push(() => {
|
|
910
|
+
const turn = active.get(uri);
|
|
911
|
+
if (!turn)
|
|
912
|
+
return;
|
|
913
|
+
turn.state = options.failed ? 'failed' : 'complete';
|
|
914
|
+
turn.elapsedMs = 18_200;
|
|
915
|
+
turns.get(uri)?.push(turn);
|
|
916
|
+
active.delete(uri);
|
|
917
|
+
if (options.failed)
|
|
918
|
+
failed.add(uri);
|
|
919
|
+
else
|
|
920
|
+
failed.delete(uri);
|
|
921
|
+
emit(uri, { type: 'turnComplete', turn });
|
|
922
|
+
touch(uri);
|
|
923
|
+
if (options.changes) {
|
|
924
|
+
changesets.set(uri, options.changes);
|
|
925
|
+
emit(uri, { type: 'changes', changes: options.changes });
|
|
926
|
+
}
|
|
927
|
+
// The turn is over, so whatever was waiting on it goes now. Appending to
|
|
928
|
+
// `script` from inside a step is safe: `pump` shifts one and runs it.
|
|
929
|
+
drain(uri);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* What the agent does when it is spoken to.
|
|
934
|
+
*
|
|
935
|
+
* Four shapes, and which one runs depends on what was said - a question gets
|
|
936
|
+
* an answer, "run the tests" gets a command that asks first, a choice gets an
|
|
937
|
+
* elicitation, and something that cannot work fails. One canned reply to
|
|
938
|
+
* everything is a fixture that only ever proves the client can render *it*:
|
|
939
|
+
* the short one never scrolls, the long one always does, the failing one is
|
|
940
|
+
* the only thing that renders a system notification, and none of that is
|
|
941
|
+
* exercised by a script that always says the same paragraph.
|
|
942
|
+
*/
|
|
943
|
+
function reply(uri, said) {
|
|
944
|
+
const userTurn = { id: nextId('u'), role: 'user', message: said, parts: [], state: 'complete', at: AT };
|
|
945
|
+
const model = { id: models.get(uri) ?? 'claude-opus-5' };
|
|
946
|
+
const agentTurn = { id: nextId('a'), role: 'agent', parts: [], state: 'running', model, at: AT };
|
|
947
|
+
script.push(() => {
|
|
948
|
+
turns.get(uri)?.push(userTurn);
|
|
949
|
+
emit(uri, { type: 'turnStarted', turn: userTurn });
|
|
950
|
+
active.set(uri, agentTurn);
|
|
951
|
+
emit(uri, { type: 'turnStarted', turn: agentTurn });
|
|
952
|
+
touch(uri);
|
|
953
|
+
});
|
|
954
|
+
switch (pick(said)) {
|
|
955
|
+
case 'run':
|
|
956
|
+
prose(uri, 'reasoning', 'The composer swallows single-letter keys, so a global `q` cannot exist while it has focus. ');
|
|
957
|
+
prose(uri, 'markdown', 'Two things are true at once here.\n\nThe **composer owns the keyboard** while it is focused, so every single-letter binding has to live in a focus scope rather than globally. What is left global is the modified set:\n\n- `ctrl+p` for the palette\n- `ctrl+c` to stop the turn\n\nLet me look at what the transcript does with the rest.');
|
|
958
|
+
tool(uri, {
|
|
959
|
+
name: 'Read', toolName: 'Read',
|
|
960
|
+
input: 'packages/core/src/app/input.ts',
|
|
961
|
+
intention: 'Read the input router, to see who gets a key first',
|
|
962
|
+
}, {
|
|
963
|
+
outcome: 'Read 214 lines',
|
|
964
|
+
output: 'const order = [layers, screen, surfaces, global];\n// A layer that traps focus gets the key before anything under it.',
|
|
965
|
+
});
|
|
966
|
+
asks(uri, {
|
|
967
|
+
name: 'Bash', toolName: 'Bash',
|
|
968
|
+
input: 'pnpm --filter @textui/core test -- input.test.ts',
|
|
969
|
+
intention: 'Run the input router tests',
|
|
970
|
+
confirmationTitle: 'Run a command in /github/textui?',
|
|
971
|
+
options: [
|
|
972
|
+
{ id: 'once', label: 'Allow once' },
|
|
973
|
+
{ id: 'session', label: 'Allow for this session' },
|
|
974
|
+
],
|
|
975
|
+
});
|
|
976
|
+
return;
|
|
977
|
+
case 'ask':
|
|
978
|
+
prose(uri, 'markdown', 'Both work, and they fail differently, so this is yours to pick rather than mine.');
|
|
979
|
+
elicits(uri, {
|
|
980
|
+
message: 'Two ways to keep the composer from eating the keys.',
|
|
981
|
+
questions: [
|
|
982
|
+
{
|
|
983
|
+
id: 'q1',
|
|
984
|
+
kind: 'single-select',
|
|
985
|
+
message: 'Where should the single-letter keys be registered?',
|
|
986
|
+
required: true,
|
|
987
|
+
options: [
|
|
988
|
+
{ id: 'transcript-scope', label: 'On the transcript scope, so the composer never sees them' },
|
|
989
|
+
{ id: 'composer-escape', label: 'Globally, and escape blurs the composer first' },
|
|
990
|
+
{ id: 'both', label: 'Both, with the transcript winning' },
|
|
991
|
+
],
|
|
992
|
+
allowFreeformInput: true,
|
|
993
|
+
},
|
|
994
|
+
{ id: 'q2', kind: 'boolean', message: 'Add a test that types into the composer and asserts q is not quit?' },
|
|
995
|
+
],
|
|
996
|
+
});
|
|
997
|
+
return;
|
|
998
|
+
case 'name':
|
|
999
|
+
/*
|
|
1000
|
+
* A question with nothing to choose from.
|
|
1001
|
+
*
|
|
1002
|
+
* The other elicitation is a choice, and a choice is answerable with
|
|
1003
|
+
* the arrow keys - so a fixture that only ever asks one hides the kind
|
|
1004
|
+
* that needs the keyboard. This is what a real host sends when it
|
|
1005
|
+
* wants a file, a symbol or a sentence back, and it is the shape that
|
|
1006
|
+
* was unanswerable: the field was drawn and what was typed at it went
|
|
1007
|
+
* into the composer behind.
|
|
1008
|
+
*/
|
|
1009
|
+
elicits(uri, {
|
|
1010
|
+
message: 'I can look, but not at all of it at once.',
|
|
1011
|
+
questions: [
|
|
1012
|
+
{
|
|
1013
|
+
id: 'q1',
|
|
1014
|
+
kind: 'text',
|
|
1015
|
+
message: 'Which specific bug, failing test, or file should I investigate?',
|
|
1016
|
+
required: true,
|
|
1017
|
+
},
|
|
1018
|
+
],
|
|
1019
|
+
});
|
|
1020
|
+
return;
|
|
1021
|
+
case 'fail':
|
|
1022
|
+
prose(uri, 'reasoning', 'Check the host answered at all before blaming the harness. ');
|
|
1023
|
+
tool(uri, {
|
|
1024
|
+
name: 'Bash', toolName: 'Bash',
|
|
1025
|
+
input: 'ssh buildbox -p 22 make -f Makefile.linux',
|
|
1026
|
+
intention: 'Build on the FreeBSD box',
|
|
1027
|
+
}, { status: 'failed', outcome: 'Exited 255', exitCode: 255, output: 'ssh: connect to host build.example.com port 22: Connection timed out' });
|
|
1028
|
+
finish(uri, 'The box did not answer on 22. That is the tunnel, not the build - nothing was compiled, so nothing is broken.', { failed: true });
|
|
1029
|
+
return;
|
|
1030
|
+
default:
|
|
1031
|
+
// Short, and with no tool calls at all. The shape a transcript is
|
|
1032
|
+
// least often tested against, because a fixture is always the long one.
|
|
1033
|
+
prose(uri, 'markdown', `Yes - **${said.trim().slice(0, 40)}** is the part that matters.\n\nThe focused node is offered the key first, so a binding only exists where its scope is mounted.`);
|
|
1034
|
+
finish(uri, '');
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Which script.
|
|
1039
|
+
*
|
|
1040
|
+
* Read off what was said, so a person driving the example can choose what to
|
|
1041
|
+
* exercise; the counter only decides when the words say nothing, which keeps
|
|
1042
|
+
* "hello" from being the same conversation every time.
|
|
1043
|
+
*/
|
|
1044
|
+
let rotation = 0;
|
|
1045
|
+
function pick(said) {
|
|
1046
|
+
const text = said.toLowerCase();
|
|
1047
|
+
// Failure first: "the build fails" names both, and the interesting half of
|
|
1048
|
+
// it is the failure.
|
|
1049
|
+
if (/\b(fail|fails|error|broken|ssh|buildbox|timeout)\b/.test(text))
|
|
1050
|
+
return 'fail';
|
|
1051
|
+
if (/\b(run|test|tests|build|compile|pnpm|make)\b/.test(text))
|
|
1052
|
+
return 'run';
|
|
1053
|
+
if (/\b(which|choose|option|options|prefer)\b/.test(text))
|
|
1054
|
+
return 'ask';
|
|
1055
|
+
// Before the rotation, and after the rest: a question with nothing to
|
|
1056
|
+
// choose from is the elicitation that has to be typed at.
|
|
1057
|
+
if (/\b(look|find|investigate|somewhere|anything)\b/.test(text))
|
|
1058
|
+
return 'name';
|
|
1059
|
+
return ['short', 'run', 'ask', 'fail'][rotation++ % 4] ?? 'short';
|
|
1060
|
+
}
|
|
1061
|
+
/** What it does once the command has been allowed. */
|
|
1062
|
+
function afterApproval(uri, call, approved) {
|
|
1063
|
+
script.push(() => {
|
|
1064
|
+
call.status = approved ? 'completed' : 'cancelled';
|
|
1065
|
+
call.outcome = approved ? 'Ran in 4.2s, 38 passed' : 'Denied';
|
|
1066
|
+
if (approved)
|
|
1067
|
+
call.output = 'Test Files 1 passed (1)\n Tests 38 passed (38)';
|
|
1068
|
+
call.exitCode = approved ? 0 : undefined;
|
|
1069
|
+
emit(uri, { type: 'toolCall', call });
|
|
1070
|
+
});
|
|
1071
|
+
if (!approved) {
|
|
1072
|
+
finish(uri, 'Left it alone. Tell me what you want to run instead.');
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
// A question, which is not a confirmation: no tool call, its own prose,
|
|
1076
|
+
// and choices that are lost entirely if it is rendered as a yes/no.
|
|
1077
|
+
elicits(uri, {
|
|
1078
|
+
message: 'The tests pass, so the fix is a choice about where the keys live.',
|
|
1079
|
+
questions: [
|
|
1080
|
+
{
|
|
1081
|
+
id: 'q1',
|
|
1082
|
+
kind: 'single-select',
|
|
1083
|
+
message: 'Where should the single-letter keys be registered?',
|
|
1084
|
+
required: true,
|
|
1085
|
+
options: [
|
|
1086
|
+
{ id: 'transcript-scope', label: 'On the transcript scope, so the composer never sees them' },
|
|
1087
|
+
{ id: 'composer-escape', label: 'Globally, and escape blurs the composer first' },
|
|
1088
|
+
{ id: 'both', label: 'Both, with the transcript winning' },
|
|
1089
|
+
],
|
|
1090
|
+
allowFreeformInput: true,
|
|
1091
|
+
},
|
|
1092
|
+
{ id: 'q2', kind: 'boolean', message: 'Add a test that types into the composer and asserts q is not quit?' },
|
|
1093
|
+
],
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
/** What the script says next, once the question has been answered. */
|
|
1097
|
+
function answered(uri, accepted, answers) {
|
|
1098
|
+
const chosen = answers.q1;
|
|
1099
|
+
const where = !accepted ? 'nothing'
|
|
1100
|
+
: chosen?.kind === 'selected' ? chosen.value
|
|
1101
|
+
: chosen?.kind === 'text' ? chosen.value : 'nothing';
|
|
1102
|
+
finish(uri, `Right - **${where}**. I will move the bindings and leave the modified keys where they are.`, {
|
|
1103
|
+
changes: {
|
|
1104
|
+
status: 'complete',
|
|
1105
|
+
files: [
|
|
1106
|
+
{ uri: 'file:///github/textui/examples/chat/src/control.ts', before: 'x', after: 'y', diff: { added: 34, removed: 6 } },
|
|
1107
|
+
{ uri: 'file:///github/textui/examples/chat/test/keys.test.tsx', after: 'y', diff: { added: 51, removed: 0 } },
|
|
1108
|
+
],
|
|
1109
|
+
},
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
/** One scripted step. The application's ticker and a test's loop share it. */
|
|
1113
|
+
function pump() {
|
|
1114
|
+
const step = script.shift();
|
|
1115
|
+
if (!step)
|
|
1116
|
+
return false;
|
|
1117
|
+
step();
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
// --------------------------------------------------------------- connection
|
|
1121
|
+
return {
|
|
1122
|
+
id: 'fake',
|
|
1123
|
+
url: 'fake://scripted',
|
|
1124
|
+
state: () => 'connected',
|
|
1125
|
+
listSessions: async () => [...summaries.values()],
|
|
1126
|
+
agents: async () => AGENTS,
|
|
1127
|
+
// Iterative, as a real host's is: what has been answered comes back
|
|
1128
|
+
// 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
|
+
}),
|
|
1134
|
+
automations: async () => [...automations.values()],
|
|
1135
|
+
onAutomations: (observer) => {
|
|
1136
|
+
automationWatchers.add(observer);
|
|
1137
|
+
return { close: () => { automationWatchers.delete(observer); } };
|
|
1138
|
+
},
|
|
1139
|
+
createAutomation: async (definition) => {
|
|
1140
|
+
const uri = `ahp-automation:/${(0x8000 + automations.size).toString(16)}`;
|
|
1141
|
+
const triggers = Array.isArray(definition.triggers) ? definition.triggers : [];
|
|
1142
|
+
const schedule = triggers
|
|
1143
|
+
.map((one) => (typeof one === 'object' && one !== null ? one : {}))
|
|
1144
|
+
.find((one) => one.kind === 'schedule');
|
|
1145
|
+
const timing = (typeof schedule?.schedule === 'object' && schedule.schedule !== null
|
|
1146
|
+
? schedule.schedule
|
|
1147
|
+
: {});
|
|
1148
|
+
automations.set(uri, {
|
|
1149
|
+
resource: uri,
|
|
1150
|
+
title: typeof definition.title === 'string' ? definition.title : 'Untitled automation',
|
|
1151
|
+
enabled: definition.enabled !== false,
|
|
1152
|
+
...(timing.expression
|
|
1153
|
+
? { schedule: { expression: timing.expression, timeZone: timing.timeZone ?? 'UTC' } }
|
|
1154
|
+
: {}),
|
|
1155
|
+
// A host with a clock answers with when it will fire, and that answer
|
|
1156
|
+
// is the confirmation the form is waiting for. An hour from now, so
|
|
1157
|
+
// the fixture is a *next* run whenever this is read.
|
|
1158
|
+
...(timing.expression
|
|
1159
|
+
? { nextRunAt: new Date(Date.now() + 3_600_000).toISOString() }
|
|
1160
|
+
: {}),
|
|
1161
|
+
runs: [],
|
|
1162
|
+
operations: ['update', 'remove', 'run'],
|
|
1163
|
+
});
|
|
1164
|
+
automationsMoved();
|
|
1165
|
+
return uri;
|
|
1166
|
+
},
|
|
1167
|
+
/**
|
|
1168
|
+
* Run one now, which is what the host does with nobody watching.
|
|
1169
|
+
*
|
|
1170
|
+
* The session it starts carries the origin, because that is the whole
|
|
1171
|
+
* point of the field: a catalogue that showed this next to one somebody
|
|
1172
|
+
* typed, with nothing to tell them apart, is what 0.9.0 added it for.
|
|
1173
|
+
*/
|
|
1174
|
+
runAutomation: async (uri) => {
|
|
1175
|
+
const found = automations.get(uri);
|
|
1176
|
+
if (!found || !found.operations.includes('run'))
|
|
1177
|
+
return;
|
|
1178
|
+
const run = `ahp-automation-run:/${(0x100 + found.runs.length).toString(16)}`;
|
|
1179
|
+
const session = `ahp-session:/${(0x1000 + summaries.size).toString(16)}`;
|
|
1180
|
+
seed({
|
|
1181
|
+
id: session,
|
|
1182
|
+
provider: 'claude',
|
|
1183
|
+
title: found.title,
|
|
1184
|
+
dir: 'file:///brb_main/src/brb_framework',
|
|
1185
|
+
read: false,
|
|
1186
|
+
origin: { kind: 'automation', automation: uri, run },
|
|
1187
|
+
});
|
|
1188
|
+
automations.set(uri, {
|
|
1189
|
+
...found,
|
|
1190
|
+
runs: [{ resource: run, status: 'running', session, triggered: false }, ...found.runs],
|
|
1191
|
+
});
|
|
1192
|
+
moved();
|
|
1193
|
+
automationsMoved();
|
|
1194
|
+
},
|
|
1195
|
+
setAutomationEnabled: async (uri, enabled) => {
|
|
1196
|
+
const found = automations.get(uri);
|
|
1197
|
+
if (!found)
|
|
1198
|
+
return;
|
|
1199
|
+
automations.set(uri, {
|
|
1200
|
+
...found,
|
|
1201
|
+
enabled,
|
|
1202
|
+
// What the host would answer with, rather than what was asked for: an
|
|
1203
|
+
// automation switched off stops offering Run, and switching it back on
|
|
1204
|
+
// offers it again.
|
|
1205
|
+
operations: enabled ? ['update', 'remove', 'run'] : ['update', 'remove'],
|
|
1206
|
+
...(enabled ? {} : { nextRunAt: undefined }),
|
|
1207
|
+
});
|
|
1208
|
+
automationsMoved();
|
|
1209
|
+
},
|
|
1210
|
+
/*
|
|
1211
|
+
* A log this fixture emits on a timer.
|
|
1212
|
+
*
|
|
1213
|
+
* Enough to drive a reader: the shape a record arrives in, and the fact
|
|
1214
|
+
* that closing stops it. A fixture with no logs at all would let a screen
|
|
1215
|
+
* ship that never rendered one.
|
|
1216
|
+
*/
|
|
1217
|
+
watchLogs: async (observer) => {
|
|
1218
|
+
let stopped = false;
|
|
1219
|
+
let n = 0;
|
|
1220
|
+
const tick = () => {
|
|
1221
|
+
if (stopped)
|
|
1222
|
+
return;
|
|
1223
|
+
n += 1;
|
|
1224
|
+
observer({
|
|
1225
|
+
at: AT,
|
|
1226
|
+
severity: n % 3 === 0 ? 'WARN' : 'INFO',
|
|
1227
|
+
body: `scripted log record ${String(n)}`,
|
|
1228
|
+
attributes: { 'service.name': 'fake-agent-host' },
|
|
1229
|
+
});
|
|
1230
|
+
setTimeout(tick, 200);
|
|
1231
|
+
};
|
|
1232
|
+
setTimeout(tick, 50);
|
|
1233
|
+
return { close: () => { stopped = true; } };
|
|
1234
|
+
},
|
|
1235
|
+
automationTriggers: async () => [
|
|
1236
|
+
{ kind: 'schedule', title: 'On a schedule', description: 'A cron expression in a time zone.' },
|
|
1237
|
+
// What a host that has events advertises. Without asking, only the
|
|
1238
|
+
// first of these could ever be authored.
|
|
1239
|
+
{ kind: 'sessionFinished', title: 'When a session finishes' },
|
|
1240
|
+
{ kind: 'changesetOpened', title: 'When a changeset opens' },
|
|
1241
|
+
],
|
|
1242
|
+
automationRuns: async (uri, cursor) => {
|
|
1243
|
+
const all = (automations.get(uri)?.runs ?? []);
|
|
1244
|
+
const page = cursor === undefined ? 0 : Number(cursor);
|
|
1245
|
+
const size = 2;
|
|
1246
|
+
const rows = all.slice(page * size, (page + 1) * size);
|
|
1247
|
+
const next = (page + 1) * size < all.length ? String(page + 1) : undefined;
|
|
1248
|
+
return { runs: rows, ...(next === undefined ? {} : { nextCursor: next }) };
|
|
1249
|
+
},
|
|
1250
|
+
removeAutomation: async (uri) => {
|
|
1251
|
+
if (!automations.delete(uri))
|
|
1252
|
+
return;
|
|
1253
|
+
automationsMoved();
|
|
1254
|
+
},
|
|
1255
|
+
createSession: async ({ provider, workingDirectory }) => {
|
|
1256
|
+
const uri = `ahp-session:/${(0x1000 + summaries.size).toString(16)}`;
|
|
1257
|
+
seed({
|
|
1258
|
+
id: uri,
|
|
1259
|
+
provider,
|
|
1260
|
+
title: 'New session',
|
|
1261
|
+
dir: workingDirectory ? `file://${workingDirectory}` : '',
|
|
1262
|
+
});
|
|
1263
|
+
moved();
|
|
1264
|
+
return uri;
|
|
1265
|
+
},
|
|
1266
|
+
disposeSession: async (uri) => {
|
|
1267
|
+
summaries.delete(uri);
|
|
1268
|
+
moved();
|
|
1269
|
+
turns.delete(uri);
|
|
1270
|
+
active.delete(uri);
|
|
1271
|
+
inputs.delete(uri);
|
|
1272
|
+
chats.delete(uri);
|
|
1273
|
+
flags.delete(uri);
|
|
1274
|
+
failed.delete(uri);
|
|
1275
|
+
},
|
|
1276
|
+
setArchived: (uri, archived) => setFlag(uri, SessionFlag.IsArchived, archived),
|
|
1277
|
+
setRead: (uri, read) => setFlag(uri, SessionFlag.IsRead, read),
|
|
1278
|
+
rename: (uri, title) => {
|
|
1279
|
+
const summary = summaries.get(uri);
|
|
1280
|
+
if (!summary)
|
|
1281
|
+
return;
|
|
1282
|
+
summaries.set(uri, { ...summary, title });
|
|
1283
|
+
moved();
|
|
1284
|
+
},
|
|
1285
|
+
onSessions: (observer) => {
|
|
1286
|
+
catalogue.add(observer);
|
|
1287
|
+
return { close: () => { catalogue.delete(observer); } };
|
|
1288
|
+
},
|
|
1289
|
+
/**
|
|
1290
|
+
* A scripted filesystem, for the one thing a menu of paths has to get
|
|
1291
|
+
* right: replacing the fragment rather than appending to it.
|
|
1292
|
+
*
|
|
1293
|
+
* Not a real directory. The point of the script is arriving at a
|
|
1294
|
+
* particular state on purpose, and a fixture that read this machine's
|
|
1295
|
+
* files would answer differently on every machine it ran on.
|
|
1296
|
+
*/
|
|
1297
|
+
/*
|
|
1298
|
+
* A scripted shell.
|
|
1299
|
+
*
|
|
1300
|
+
* It answers three commands and says so for anything else, which is
|
|
1301
|
+
* enough to check the one thing a terminal view has to get right:
|
|
1302
|
+
* keystrokes go out, output comes back, and what is on screen is the
|
|
1303
|
+
* accumulated stream rather than the last thing said.
|
|
1304
|
+
*/
|
|
1305
|
+
terminals: async () => [...shells].map(([resource, held]) => ({
|
|
1306
|
+
resource,
|
|
1307
|
+
title: held.title,
|
|
1308
|
+
...(held.exitCode !== undefined ? { exitCode: held.exitCode } : {}),
|
|
1309
|
+
})),
|
|
1310
|
+
createTerminal: async (options) => {
|
|
1311
|
+
const uri = `ahp-terminal:/${randomUUID()}`;
|
|
1312
|
+
shells.set(uri, {
|
|
1313
|
+
title: options?.name ?? 'sh',
|
|
1314
|
+
cwd: options?.cwd ?? '/brb_main/src/brb_framework',
|
|
1315
|
+
output: '',
|
|
1316
|
+
pending: '',
|
|
1317
|
+
watchers: new Set(),
|
|
1318
|
+
});
|
|
1319
|
+
return uri;
|
|
1320
|
+
},
|
|
1321
|
+
disposeTerminal: async (uri) => {
|
|
1322
|
+
const held = shells.get(uri);
|
|
1323
|
+
if (!held)
|
|
1324
|
+
return;
|
|
1325
|
+
held.exitCode = 0;
|
|
1326
|
+
for (const watcher of held.watchers)
|
|
1327
|
+
watcher(shellState(uri, held));
|
|
1328
|
+
shells.delete(uri);
|
|
1329
|
+
},
|
|
1330
|
+
watchTerminal: (uri, observer) => {
|
|
1331
|
+
const held = shells.get(uri);
|
|
1332
|
+
if (!held)
|
|
1333
|
+
return { close: () => { } };
|
|
1334
|
+
held.watchers.add(observer);
|
|
1335
|
+
observer(shellState(uri, held));
|
|
1336
|
+
return { close: () => { held.watchers.delete(observer); } };
|
|
1337
|
+
},
|
|
1338
|
+
resizeTerminal: (uri, cols, rows) => {
|
|
1339
|
+
const held = shells.get(uri);
|
|
1340
|
+
if (!held)
|
|
1341
|
+
return;
|
|
1342
|
+
held.cols = cols;
|
|
1343
|
+
held.rows = rows;
|
|
1344
|
+
for (const watcher of held.watchers)
|
|
1345
|
+
watcher(shellState(uri, held));
|
|
1346
|
+
},
|
|
1347
|
+
clearTerminal: (uri) => {
|
|
1348
|
+
const held = shells.get(uri);
|
|
1349
|
+
if (!held)
|
|
1350
|
+
return;
|
|
1351
|
+
held.output = '';
|
|
1352
|
+
for (const watcher of held.watchers)
|
|
1353
|
+
watcher(shellState(uri, held));
|
|
1354
|
+
},
|
|
1355
|
+
renameTerminal: (uri, title) => {
|
|
1356
|
+
const held = shells.get(uri);
|
|
1357
|
+
if (!held)
|
|
1358
|
+
return;
|
|
1359
|
+
held.title = title;
|
|
1360
|
+
for (const watcher of held.watchers)
|
|
1361
|
+
watcher(shellState(uri, held));
|
|
1362
|
+
},
|
|
1363
|
+
claimTerminal: (uri) => {
|
|
1364
|
+
const held = shells.get(uri);
|
|
1365
|
+
if (!held)
|
|
1366
|
+
return;
|
|
1367
|
+
held.claim = 'fake-client';
|
|
1368
|
+
for (const watcher of held.watchers)
|
|
1369
|
+
watcher(shellState(uri, held));
|
|
1370
|
+
},
|
|
1371
|
+
writeTerminal: (uri, data) => {
|
|
1372
|
+
const held = shells.get(uri);
|
|
1373
|
+
if (!held)
|
|
1374
|
+
return;
|
|
1375
|
+
held.pending += data;
|
|
1376
|
+
// A line at a time, which is what a shell reading from a pipe does.
|
|
1377
|
+
for (;;) {
|
|
1378
|
+
const at = held.pending.indexOf('\n');
|
|
1379
|
+
if (at === -1)
|
|
1380
|
+
break;
|
|
1381
|
+
const line = held.pending.slice(0, at).trim();
|
|
1382
|
+
held.pending = held.pending.slice(at + 1);
|
|
1383
|
+
held.output += `$ ${line}\n${SHELL[line] ?? `sh: ${line}: not found\n`}`;
|
|
1384
|
+
}
|
|
1385
|
+
for (const watcher of held.watchers)
|
|
1386
|
+
watcher(shellState(uri, held));
|
|
1387
|
+
},
|
|
1388
|
+
completions: async ({ text, offset }) => {
|
|
1389
|
+
const at = offset ?? text.length;
|
|
1390
|
+
const found = /(?:^|\s)@(\S*)$/.exec(text.slice(0, at));
|
|
1391
|
+
if (!found)
|
|
1392
|
+
return [];
|
|
1393
|
+
const typed = found[1] ?? '';
|
|
1394
|
+
const start = at - typed.length - 1;
|
|
1395
|
+
const cut = typed.lastIndexOf('/');
|
|
1396
|
+
const inside = cut === -1 ? '' : typed.slice(0, cut + 1);
|
|
1397
|
+
const prefix = cut === -1 ? typed : typed.slice(cut + 1);
|
|
1398
|
+
const here = FILES[inside] ?? [];
|
|
1399
|
+
return here
|
|
1400
|
+
.filter((name) => name.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
1401
|
+
.map((name) => ({
|
|
1402
|
+
insertText: `@${inside}${name}`,
|
|
1403
|
+
rangeStart: start,
|
|
1404
|
+
rangeEnd: at,
|
|
1405
|
+
label: `${inside}${name}`,
|
|
1406
|
+
}));
|
|
1407
|
+
},
|
|
1408
|
+
createChat: async (uri, first, source) => {
|
|
1409
|
+
const chat = `ahp-chat:/${randomUUID()}`;
|
|
1410
|
+
// A fork copies the source's visible history through the named turn; a
|
|
1411
|
+
// side chat carries the context without copying it into what a person
|
|
1412
|
+
// reads. A fixture that treated them alike would let a screen ship that
|
|
1413
|
+
// could not tell them apart either.
|
|
1414
|
+
const from = source === undefined ? [] : (turns.get(source.chat) ?? extra.get(source.chat)?.turns ?? []);
|
|
1415
|
+
const carried = source?.kind === 'fork'
|
|
1416
|
+
? [...from.slice(0, Math.max(1, from.findIndex((one) => one.id === source.turnId) + 1))]
|
|
1417
|
+
: [];
|
|
1418
|
+
extra.set(chat, {
|
|
1419
|
+
session: uri,
|
|
1420
|
+
title: source?.kind === 'sideChat' ? 'Side chat' : 'Chat',
|
|
1421
|
+
turns: carried.map((one) => ({ ...one })),
|
|
1422
|
+
watchers: new Set(),
|
|
1423
|
+
});
|
|
1424
|
+
emit(uri, { type: 'chats', items: chatsOf(uri), defaultChat: chats.get(uri) ?? '' });
|
|
1425
|
+
if (first) {
|
|
1426
|
+
const held = extra.get(chat);
|
|
1427
|
+
held?.turns.push({ id: `${chat}:said`, role: 'user', message: first, parts: [], state: 'complete', at: AT });
|
|
1428
|
+
}
|
|
1429
|
+
return chat;
|
|
1430
|
+
},
|
|
1431
|
+
disposeChat: async (chat) => {
|
|
1432
|
+
const held = extra.get(chat);
|
|
1433
|
+
if (!held) {
|
|
1434
|
+
// The session's own chat is the session. Saying so beats a silent
|
|
1435
|
+
// no-op, which reads as a close that did not take.
|
|
1436
|
+
throw new Error('That is the only chat in this session; dispose the session instead');
|
|
1437
|
+
}
|
|
1438
|
+
extra.delete(chat);
|
|
1439
|
+
emit(held.session, { type: 'chats', items: chatsOf(held.session), defaultChat: chats.get(held.session) ?? '' });
|
|
1440
|
+
},
|
|
1441
|
+
subscribe: (uri, observer, wanted) => {
|
|
1442
|
+
const held = wanted === undefined ? undefined : extra.get(wanted);
|
|
1443
|
+
if (held) {
|
|
1444
|
+
held.watchers.add(observer);
|
|
1445
|
+
observer({ type: 'snapshot', turns: held.turns, status: SessionFlag.Idle, queued: [], draft: '' });
|
|
1446
|
+
observer({ type: 'chats', items: chatsOf(uri), defaultChat: chats.get(uri) ?? '' });
|
|
1447
|
+
return { close: () => { held.watchers.delete(observer); } };
|
|
1448
|
+
}
|
|
1449
|
+
let set = observers.get(uri);
|
|
1450
|
+
if (!set) {
|
|
1451
|
+
set = new Set();
|
|
1452
|
+
observers.set(uri, set);
|
|
1453
|
+
}
|
|
1454
|
+
set.add(observer);
|
|
1455
|
+
observer({
|
|
1456
|
+
type: 'snapshot',
|
|
1457
|
+
turns: turns.get(uri) ?? [],
|
|
1458
|
+
...(active.get(uri) ? { active: active.get(uri) } : {}),
|
|
1459
|
+
...(inputs.get(uri) ? { input: inputs.get(uri) } : {}),
|
|
1460
|
+
status: statusOf(uri),
|
|
1461
|
+
queued: queues.get(uri) ?? [],
|
|
1462
|
+
draft: drafts.get(uri) ?? '',
|
|
1463
|
+
});
|
|
1464
|
+
observer({ type: 'chats', items: chatsOf(uri), defaultChat: chats.get(uri) ?? '' });
|
|
1465
|
+
const changes = changesets.get(uri);
|
|
1466
|
+
if (changes)
|
|
1467
|
+
observer({ type: 'changes', changes });
|
|
1468
|
+
// Closing drops this consumer. It does not unsubscribe the channel -
|
|
1469
|
+
// doing that to shed a duplicate is what kills the stream everything
|
|
1470
|
+
// else is reading.
|
|
1471
|
+
return { close: () => { set?.delete(observer); } };
|
|
1472
|
+
},
|
|
1473
|
+
loadOlderTurns: async (uri) => {
|
|
1474
|
+
const behind = older.get(uri) ?? [];
|
|
1475
|
+
if (behind.length === 0)
|
|
1476
|
+
return false;
|
|
1477
|
+
// A page, oldest last: the ones nearest the loaded window come first,
|
|
1478
|
+
// which is the order a host hands them back in.
|
|
1479
|
+
const page = behind.splice(-PAGE);
|
|
1480
|
+
turns.set(uri, [...page, ...(turns.get(uri) ?? [])]);
|
|
1481
|
+
emit(uri, {
|
|
1482
|
+
type: 'snapshot',
|
|
1483
|
+
turns: turns.get(uri) ?? [],
|
|
1484
|
+
...(active.get(uri) ? { active: active.get(uri) } : {}),
|
|
1485
|
+
...(inputs.get(uri) ? { input: inputs.get(uri) } : {}),
|
|
1486
|
+
status: statusOf(uri),
|
|
1487
|
+
queued: queues.get(uri) ?? [],
|
|
1488
|
+
draft: drafts.get(uri) ?? '',
|
|
1489
|
+
});
|
|
1490
|
+
return behind.length > 0;
|
|
1491
|
+
},
|
|
1492
|
+
setDraft: (uri, text) => {
|
|
1493
|
+
if (text === '')
|
|
1494
|
+
drafts.delete(uri);
|
|
1495
|
+
else
|
|
1496
|
+
drafts.set(uri, text);
|
|
1497
|
+
emit(uri, {
|
|
1498
|
+
type: 'snapshot',
|
|
1499
|
+
turns: turns.get(uri) ?? [],
|
|
1500
|
+
...(active.get(uri) ? { active: active.get(uri) } : {}),
|
|
1501
|
+
...(inputs.get(uri) ? { input: inputs.get(uri) } : {}),
|
|
1502
|
+
status: statusOf(uri),
|
|
1503
|
+
queued: queues.get(uri) ?? [],
|
|
1504
|
+
draft: drafts.get(uri) ?? '',
|
|
1505
|
+
});
|
|
1506
|
+
},
|
|
1507
|
+
// Sending clears the draft, which is what the host does.
|
|
1508
|
+
say: (uri, text) => { drafts.delete(uri); reply(uri, text); },
|
|
1509
|
+
queue: (uri, text) => {
|
|
1510
|
+
const waiting = [...(queues.get(uri) ?? []), { id: nextId('q'), text }];
|
|
1511
|
+
queues.set(uri, waiting);
|
|
1512
|
+
emit(uri, { type: 'queued', messages: waiting });
|
|
1513
|
+
// Idle already: the protocol says a host consumes a queued message
|
|
1514
|
+
// immediately rather than holding it for a turn that is not running.
|
|
1515
|
+
drain(uri);
|
|
1516
|
+
},
|
|
1517
|
+
unqueue: (uri, id) => {
|
|
1518
|
+
const waiting = (queues.get(uri) ?? []).filter((message) => message.id !== id);
|
|
1519
|
+
queues.set(uri, waiting);
|
|
1520
|
+
emit(uri, { type: 'queued', messages: waiting });
|
|
1521
|
+
},
|
|
1522
|
+
stopTurn: stop,
|
|
1523
|
+
/*
|
|
1524
|
+
* Both answers land on the next step, not inside the call.
|
|
1525
|
+
*
|
|
1526
|
+
* A host answers over a socket, and a fake that has resolved the question
|
|
1527
|
+
* before its own caller has returned is one where the interval between
|
|
1528
|
+
* pressing a button and being told anything does not exist. That interval
|
|
1529
|
+
* is the whole of what a client has to draw - it is where "I pressed
|
|
1530
|
+
* Approve and nothing happened" lives - so the script has it too.
|
|
1531
|
+
*/
|
|
1532
|
+
confirmToolCall: (uri, toolCallId, approved) => {
|
|
1533
|
+
const input = inputs.get(uri);
|
|
1534
|
+
if (!input || input.kind !== 'toolConfirmation' || input.call.id !== toolCallId)
|
|
1535
|
+
return;
|
|
1536
|
+
// At the front: whatever the script already holds comes after the
|
|
1537
|
+
// question is let go of, never before it.
|
|
1538
|
+
script.unshift(() => {
|
|
1539
|
+
inputs.delete(uri);
|
|
1540
|
+
emit(uri, { type: 'inputResolved' });
|
|
1541
|
+
touch(uri);
|
|
1542
|
+
afterApproval(uri, input.call, approved);
|
|
1543
|
+
});
|
|
1544
|
+
},
|
|
1545
|
+
completeInput: (uri, requestId, accepted, answers) => {
|
|
1546
|
+
const input = inputs.get(uri);
|
|
1547
|
+
if (!input || input.id !== requestId)
|
|
1548
|
+
return;
|
|
1549
|
+
script.unshift(() => {
|
|
1550
|
+
inputs.delete(uri);
|
|
1551
|
+
emit(uri, { type: 'inputResolved' });
|
|
1552
|
+
touch(uri);
|
|
1553
|
+
answered(uri, accepted, answers);
|
|
1554
|
+
});
|
|
1555
|
+
},
|
|
1556
|
+
/**
|
|
1557
|
+
* Which changesets a session offers.
|
|
1558
|
+
*
|
|
1559
|
+
* Four, the way the protocol has them: two that are already URIs and two
|
|
1560
|
+
* that are templates a client has to fill in from turns it can see. A
|
|
1561
|
+
* session with nothing changed offers none, which is what makes "the host
|
|
1562
|
+
* advertises no scopes" a case a screen can be built against.
|
|
1563
|
+
*/
|
|
1564
|
+
changesets: async (uri) => {
|
|
1565
|
+
const per = scoped.get(uri);
|
|
1566
|
+
if (!per)
|
|
1567
|
+
return [];
|
|
1568
|
+
return [
|
|
1569
|
+
{
|
|
1570
|
+
label: 'This Session',
|
|
1571
|
+
description: 'Everything this conversation changed',
|
|
1572
|
+
uriTemplate: `${uri}/changeset/session`,
|
|
1573
|
+
changeKind: 'session',
|
|
1574
|
+
reviewable: true,
|
|
1575
|
+
variables: [],
|
|
1576
|
+
},
|
|
1577
|
+
{
|
|
1578
|
+
label: 'Uncommitted Changes',
|
|
1579
|
+
description: 'The working tree, against HEAD',
|
|
1580
|
+
uriTemplate: `${uri}/changeset/uncommitted`,
|
|
1581
|
+
changeKind: 'uncommitted',
|
|
1582
|
+
variables: [],
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
label: 'This Turn',
|
|
1586
|
+
description: 'What one turn changed',
|
|
1587
|
+
uriTemplate: `${uri}/changeset/turn/{turnId}`,
|
|
1588
|
+
changeKind: 'turn',
|
|
1589
|
+
reviewable: true,
|
|
1590
|
+
variables: ['turnId'],
|
|
1591
|
+
},
|
|
1592
|
+
{
|
|
1593
|
+
label: 'Between Two Turns',
|
|
1594
|
+
uriTemplate: `${uri}/changeset/compare/{originalTurnId}/{modifiedTurnId}`,
|
|
1595
|
+
changeKind: 'compare-turns',
|
|
1596
|
+
reviewable: true,
|
|
1597
|
+
variables: ['originalTurnId', 'modifiedTurnId'],
|
|
1598
|
+
},
|
|
1599
|
+
];
|
|
1600
|
+
},
|
|
1601
|
+
/**
|
|
1602
|
+
* One of them, by the URI its template became.
|
|
1603
|
+
*
|
|
1604
|
+
* The second argument is honoured rather than ignored: it is the whole
|
|
1605
|
+
* difference between a screen that can show four changesets and one that
|
|
1606
|
+
* shows the first and hides the rest. Left out, the session's own - which
|
|
1607
|
+
* is what a screen drawing a single changeset wants.
|
|
1608
|
+
*/
|
|
1609
|
+
changes: async (uri, wanted) => {
|
|
1610
|
+
if (wanted === undefined)
|
|
1611
|
+
return changesets.get(uri) ?? { status: 'complete', files: [] };
|
|
1612
|
+
const at = scopeIn(wanted);
|
|
1613
|
+
// A scope this session does not offer is not an empty changeset - it is
|
|
1614
|
+
// a question about something that did not happen, and a host says so.
|
|
1615
|
+
if (!at)
|
|
1616
|
+
throw new Error(`${wanted} is not a changeset of ${uri}`);
|
|
1617
|
+
const found = scoped.get(at.owner)?.get(at.scope);
|
|
1618
|
+
if (!found)
|
|
1619
|
+
throw new Error(`${wanted} is not a changeset of ${at.owner}`);
|
|
1620
|
+
return found;
|
|
1621
|
+
},
|
|
1622
|
+
/**
|
|
1623
|
+
* Ticked off, or cleared.
|
|
1624
|
+
*
|
|
1625
|
+
* The host keeps the flag and tells everyone watching, which is why this
|
|
1626
|
+
* returns nothing: a client that toggled its own copy would be the only
|
|
1627
|
+
* one that ever saw it, and would disagree with the next snapshot.
|
|
1628
|
+
*/
|
|
1629
|
+
review: (changeset, files, reviewed) => {
|
|
1630
|
+
const at = scopeIn(changeset);
|
|
1631
|
+
if (!at)
|
|
1632
|
+
return;
|
|
1633
|
+
const held = scoped.get(at.owner)?.get(at.scope);
|
|
1634
|
+
if (!held)
|
|
1635
|
+
return;
|
|
1636
|
+
const wanted = new Set(files);
|
|
1637
|
+
const after = {
|
|
1638
|
+
status: held.status,
|
|
1639
|
+
files: held.files.map((file) => {
|
|
1640
|
+
if (!wanted.has(file.uri))
|
|
1641
|
+
return file;
|
|
1642
|
+
// The key goes rather than turning `false`, because absent is what
|
|
1643
|
+
// the protocol says not-yet-reviewed is - and a row carrying
|
|
1644
|
+
// `reviewed: false` invites a client to read it as a third state.
|
|
1645
|
+
const { reviewed: _was, ...rest } = file;
|
|
1646
|
+
return reviewed ? { ...rest, reviewed: true } : rest;
|
|
1647
|
+
}),
|
|
1648
|
+
};
|
|
1649
|
+
scoped.get(at.owner)?.set(at.scope, after);
|
|
1650
|
+
if (at.scope === 'session')
|
|
1651
|
+
changesets.set(at.owner, after);
|
|
1652
|
+
emit(at.owner, { type: 'changes', changes: after });
|
|
1653
|
+
},
|
|
1654
|
+
/**
|
|
1655
|
+
* Which resources this client has talked its way into writing.
|
|
1656
|
+
*
|
|
1657
|
+
* A set on the host and not on the caller, because that is where it lives
|
|
1658
|
+
* on a real one: the grant is per connection, and a client that kept its
|
|
1659
|
+
* own copy would be the only thing that believed in it.
|
|
1660
|
+
*/
|
|
1661
|
+
requestResource: async (uri, access) => {
|
|
1662
|
+
if (!uri.startsWith('file://'))
|
|
1663
|
+
throw Object.assign(new Error(`This host does not mediate ${uri}`), { code: -32009 });
|
|
1664
|
+
if (access.write === true)
|
|
1665
|
+
granted.add(uri);
|
|
1666
|
+
},
|
|
1667
|
+
/**
|
|
1668
|
+
* Run one, with the gate a real host puts in front of it.
|
|
1669
|
+
*
|
|
1670
|
+
* Refused until the write has been asked for, and the refusal carries the
|
|
1671
|
+
* request that would unlock it - which is the whole reason a client can
|
|
1672
|
+
* negotiate rather than just fail. Scripting the refusal is the point: a
|
|
1673
|
+
* fixture that always said yes would let a client be built that never
|
|
1674
|
+
* learned to ask.
|
|
1675
|
+
*/
|
|
1676
|
+
invoke: async (changeset, operationId, target) => {
|
|
1677
|
+
const at = scopeIn(changeset);
|
|
1678
|
+
const held = at && scoped.get(at.owner)?.get(at.scope);
|
|
1679
|
+
if (!at || !held)
|
|
1680
|
+
throw new Error(`${changeset} is not a changeset here`);
|
|
1681
|
+
const offered = (held.operations ?? []).find((one) => one.id === operationId);
|
|
1682
|
+
// The advertised list *is* the access model on a real host, so a fake
|
|
1683
|
+
// that ran an unadvertised id would be a laxer host than any real one.
|
|
1684
|
+
if (!offered)
|
|
1685
|
+
throw Object.assign(new Error(`No operation called ${operationId} on ${changeset}`), { code: -32602 });
|
|
1686
|
+
const kind = target?.kind ?? 'changeset';
|
|
1687
|
+
if (!offered.scopes.includes(kind)) {
|
|
1688
|
+
throw Object.assign(new Error(`${operationId} cannot be invoked on a ${kind}`), { code: -32602 });
|
|
1689
|
+
}
|
|
1690
|
+
const wanted = target?.resource ?? (summaries.get(at.owner)?.workingDirectories[0] ?? '');
|
|
1691
|
+
if (!granted.has(wanted)) {
|
|
1692
|
+
throw Object.assign(new Error(`Write access to ${wanted} has not been granted`), {
|
|
1693
|
+
code: -32009,
|
|
1694
|
+
data: { request: { channel: 'ahp-root://', uri: wanted, write: true } },
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
invoked.push({ changeset, operationId, ...(target ? { target } : {}) });
|
|
1698
|
+
// What it did reaches every client through the changeset's own channel,
|
|
1699
|
+
// never through this answer.
|
|
1700
|
+
if (operationId === 'commit') {
|
|
1701
|
+
const after = { status: 'complete', files: [], ...(held.operations ? { operations: [] } : {}) };
|
|
1702
|
+
scoped.get(at.owner)?.set(at.scope, after);
|
|
1703
|
+
emit(at.owner, { type: 'changes', changes: after });
|
|
1704
|
+
return { message: 'Committed 1a2b3c4: what the session changed' };
|
|
1705
|
+
}
|
|
1706
|
+
const after = {
|
|
1707
|
+
...held,
|
|
1708
|
+
files: held.files.filter((file) => file.uri !== target?.resource),
|
|
1709
|
+
};
|
|
1710
|
+
scoped.get(at.owner)?.set(at.scope, after);
|
|
1711
|
+
if (at.scope === 'session')
|
|
1712
|
+
changesets.set(at.owner, after);
|
|
1713
|
+
emit(at.owner, { type: 'changes', changes: after });
|
|
1714
|
+
return { message: `${offered.label} on ${(target?.resource ?? '').split('/').pop() ?? ''}` };
|
|
1715
|
+
},
|
|
1716
|
+
/**
|
|
1717
|
+
* One directory of the host's filesystem.
|
|
1718
|
+
*
|
|
1719
|
+
* Rooted at whichever served directory the URI is under, so what this
|
|
1720
|
+
* lists is the same tree the `@` completion offers - two views of one
|
|
1721
|
+
* fixture rather than two fixtures that will drift.
|
|
1722
|
+
*/
|
|
1723
|
+
resourceList: async (uri) => {
|
|
1724
|
+
const at = inTree(uri);
|
|
1725
|
+
if (at === undefined)
|
|
1726
|
+
throw new Error(`${uri} is not a directory this host serves`);
|
|
1727
|
+
const here = FILES[at.inside];
|
|
1728
|
+
if (here === undefined)
|
|
1729
|
+
throw new Error(`${uri} is not a directory`);
|
|
1730
|
+
return here.map((name) => ({
|
|
1731
|
+
uri: `file://${at.root}/${at.inside}${name}`.replace(/\/$/, ''),
|
|
1732
|
+
name: name.replace(/\/$/, ''),
|
|
1733
|
+
kind: name.endsWith('/') ? 'directory' : 'file',
|
|
1734
|
+
...(name.endsWith('/') ? {} : { size: (SOURCES[`${at.inside}${name}`] ?? '').length }),
|
|
1735
|
+
}));
|
|
1736
|
+
},
|
|
1737
|
+
/**
|
|
1738
|
+
* One file's bytes, off the same tree.
|
|
1739
|
+
*
|
|
1740
|
+
* `encoding` is reported rather than assumed, as a real host reports it -
|
|
1741
|
+
* a caller that took every answer for text is a caller that prints a PNG
|
|
1742
|
+
* to a terminal, and a fake that only ever answers text never catches one.
|
|
1743
|
+
*/
|
|
1744
|
+
resourceRead: async (uri) => {
|
|
1745
|
+
const at = inTree(uri);
|
|
1746
|
+
const body = at === undefined ? undefined : SOURCES[at.inside];
|
|
1747
|
+
if (body === undefined)
|
|
1748
|
+
throw new Error(`${uri} is not a file this host serves`);
|
|
1749
|
+
return { data: body, encoding: 'utf-8', contentType: 'text/plain' };
|
|
1750
|
+
},
|
|
1751
|
+
/*
|
|
1752
|
+
* A token this fixture takes and remembers.
|
|
1753
|
+
*
|
|
1754
|
+
* The resource is checked against what the agents advertise, because that
|
|
1755
|
+
* is the rule a real host enforces - `authentication.md` says the value
|
|
1756
|
+
* MUST match one the server advertised, and a fixture that accepted any
|
|
1757
|
+
* string would let a client ship a name no host will take.
|
|
1758
|
+
*/
|
|
1759
|
+
authenticate: async (resource, token) => {
|
|
1760
|
+
const known = AGENTS.flatMap((agent) => agent.protectedResources ?? []);
|
|
1761
|
+
if (known.length > 0 && !known.some((one) => one.resource === resource)) {
|
|
1762
|
+
throw new Error(`This host protects ${known.map((one) => one.resource).join(', ')}, not ${resource}.`);
|
|
1763
|
+
}
|
|
1764
|
+
if (token === '')
|
|
1765
|
+
tokens.delete(resource);
|
|
1766
|
+
else
|
|
1767
|
+
tokens.set(resource, token);
|
|
1768
|
+
return undefined;
|
|
1769
|
+
},
|
|
1770
|
+
protectedResources: async () => AGENTS.flatMap((agent) => agent.protectedResources ?? []),
|
|
1771
|
+
/*
|
|
1772
|
+
* Values a schema would not carry.
|
|
1773
|
+
*
|
|
1774
|
+
* Filtered by the query, because that is what the host does with it - a
|
|
1775
|
+
* fixture that returned the whole list whatever was typed would let a
|
|
1776
|
+
* screen ship that never sent one.
|
|
1777
|
+
*/
|
|
1778
|
+
configCompletions: async ({ property, query }) => {
|
|
1779
|
+
const all = property === 'branch'
|
|
1780
|
+
? [
|
|
1781
|
+
{ value: 'main', label: 'main' },
|
|
1782
|
+
{ value: 'softov/spec-batches', label: 'softov/spec-batches' },
|
|
1783
|
+
{ value: 'softov/reconnect', label: 'softov/reconnect' },
|
|
1784
|
+
]
|
|
1785
|
+
: [];
|
|
1786
|
+
const at = (query ?? '').toLowerCase();
|
|
1787
|
+
return at === '' ? all : all.filter((one) => one.value.toLowerCase().includes(at));
|
|
1788
|
+
},
|
|
1789
|
+
resourceResolve: async (uri) => {
|
|
1790
|
+
const at = inTree(uri);
|
|
1791
|
+
if (at === undefined)
|
|
1792
|
+
throw new Error(`${uri} is not something this host serves`);
|
|
1793
|
+
const directory = FILES[at.inside] !== undefined;
|
|
1794
|
+
const body = SOURCES[at.inside];
|
|
1795
|
+
if (!directory && body === undefined)
|
|
1796
|
+
throw new Error(`${uri} is not a file this host serves`);
|
|
1797
|
+
return {
|
|
1798
|
+
uri,
|
|
1799
|
+
type: directory ? 'directory' : 'file',
|
|
1800
|
+
...(directory ? {} : { size: (body ?? '').length }),
|
|
1801
|
+
};
|
|
1802
|
+
},
|
|
1803
|
+
/*
|
|
1804
|
+
* The write half, over the same tree.
|
|
1805
|
+
*
|
|
1806
|
+
* Written into `SOURCES` rather than pretended: a fake that accepted a
|
|
1807
|
+
* write and forgot it is one where a screen that saves and re-reads looks
|
|
1808
|
+
* correct while doing nothing. `createOnly` is the protocol's guard and
|
|
1809
|
+
* refuses rather than replacing.
|
|
1810
|
+
*/
|
|
1811
|
+
resourceWrite: async (uri, data, opts) => {
|
|
1812
|
+
const at = inTree(uri);
|
|
1813
|
+
if (at === undefined)
|
|
1814
|
+
throw new Error(`${uri} is not somewhere this host serves`);
|
|
1815
|
+
if (opts?.createOnly && SOURCES[at.inside] !== undefined) {
|
|
1816
|
+
throw new Error(`${uri} already exists`);
|
|
1817
|
+
}
|
|
1818
|
+
SOURCES[at.inside] = data;
|
|
1819
|
+
return undefined;
|
|
1820
|
+
},
|
|
1821
|
+
resourceDelete: async (uri) => {
|
|
1822
|
+
const at = inTree(uri);
|
|
1823
|
+
if (at === undefined || SOURCES[at.inside] === undefined) {
|
|
1824
|
+
throw new Error(`${uri} is not a file this host serves`);
|
|
1825
|
+
}
|
|
1826
|
+
delete SOURCES[at.inside];
|
|
1827
|
+
return undefined;
|
|
1828
|
+
},
|
|
1829
|
+
resourceMkdir: async (uri) => {
|
|
1830
|
+
const at = inTree(uri);
|
|
1831
|
+
if (at === undefined)
|
|
1832
|
+
throw new Error(`${uri} is not somewhere this host serves`);
|
|
1833
|
+
FILES[at.inside] ??= [];
|
|
1834
|
+
return undefined;
|
|
1835
|
+
},
|
|
1836
|
+
resourceMove: async (from, to, opts) => {
|
|
1837
|
+
const source = inTree(from);
|
|
1838
|
+
const target = inTree(to);
|
|
1839
|
+
if (source === undefined || target === undefined)
|
|
1840
|
+
throw new Error('not somewhere this host serves');
|
|
1841
|
+
const body = SOURCES[source.inside];
|
|
1842
|
+
if (body === undefined)
|
|
1843
|
+
throw new Error(`${from} is not a file this host serves`);
|
|
1844
|
+
if (opts?.failIfExists && SOURCES[target.inside] !== undefined) {
|
|
1845
|
+
throw new Error(`${to} already exists`);
|
|
1846
|
+
}
|
|
1847
|
+
SOURCES[target.inside] = body;
|
|
1848
|
+
delete SOURCES[source.inside];
|
|
1849
|
+
return undefined;
|
|
1850
|
+
},
|
|
1851
|
+
resourceCopy: async (from, to, opts) => {
|
|
1852
|
+
const source = inTree(from);
|
|
1853
|
+
const target = inTree(to);
|
|
1854
|
+
if (source === undefined || target === undefined)
|
|
1855
|
+
throw new Error('not somewhere this host serves');
|
|
1856
|
+
const body = SOURCES[source.inside];
|
|
1857
|
+
if (body === undefined)
|
|
1858
|
+
throw new Error(`${from} is not a file this host serves`);
|
|
1859
|
+
if (opts?.failIfExists && SOURCES[target.inside] !== undefined) {
|
|
1860
|
+
throw new Error(`${to} already exists`);
|
|
1861
|
+
}
|
|
1862
|
+
SOURCES[target.inside] = body;
|
|
1863
|
+
return undefined;
|
|
1864
|
+
},
|
|
1865
|
+
/*
|
|
1866
|
+
* A watch this fixture opens and never fires.
|
|
1867
|
+
*
|
|
1868
|
+
* Which is honest: nothing changes under a scripted filesystem. What it
|
|
1869
|
+
* demonstrates is the shape a caller has to get right - a handle whose
|
|
1870
|
+
* release is the only way to close one, because the protocol has no
|
|
1871
|
+
* dispose command and the receiver releases the watcher when the last
|
|
1872
|
+
* subscriber goes.
|
|
1873
|
+
*/
|
|
1874
|
+
watchResource: async (uri, _observer) => {
|
|
1875
|
+
const at = inTree(uri);
|
|
1876
|
+
if (at === undefined)
|
|
1877
|
+
throw new Error(`${uri} is not somewhere this host serves`);
|
|
1878
|
+
watched.add(uri);
|
|
1879
|
+
return { close: () => { watched.delete(uri); } };
|
|
1880
|
+
},
|
|
1881
|
+
/**
|
|
1882
|
+
* One action, verbatim, without this fake knowing what most of them mean.
|
|
1883
|
+
*
|
|
1884
|
+
* Every one is recorded, and the handful this host can honour are carried
|
|
1885
|
+
* out. That split is the honest one: a real host handles what it handles
|
|
1886
|
+
* and ignores the rest, and a fake that silently dropped everything would
|
|
1887
|
+
* make `dispatch` untestable in exactly the place it exists to be tested.
|
|
1888
|
+
*/
|
|
1889
|
+
dispatch: (uri, action, chat) => {
|
|
1890
|
+
sent.push({ uri, action, ...(chat === true ? { chat: true } : {}) });
|
|
1891
|
+
const type = String(action.type ?? '');
|
|
1892
|
+
if (type === 'chat/turnStarted') {
|
|
1893
|
+
const message = action.message;
|
|
1894
|
+
const text = message?.text ?? String(action.content ?? '');
|
|
1895
|
+
if (text !== '')
|
|
1896
|
+
reply(uri, text);
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
if (type === 'chat/turnCancelled') {
|
|
1900
|
+
stop(uri);
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (type === 'session/isReadChanged') {
|
|
1904
|
+
setFlag(uri, SessionFlag.IsRead, action.isRead === true);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
if (type === 'session/isArchivedChanged')
|
|
1908
|
+
setFlag(uri, SessionFlag.IsArchived, action.isArchived === true);
|
|
1909
|
+
},
|
|
1910
|
+
/**
|
|
1911
|
+
* Wait for what was dispatched to have taken effect.
|
|
1912
|
+
*
|
|
1913
|
+
* On a socket this is bytes leaving; here it is the script running out,
|
|
1914
|
+
* which is the same promise from the caller's side - after it, everything
|
|
1915
|
+
* asked for has happened. A caller that dispatches one thing and exits is
|
|
1916
|
+
* the reason either exists.
|
|
1917
|
+
*/
|
|
1918
|
+
flush: async () => {
|
|
1919
|
+
for (let i = 0; i < 5000; i++)
|
|
1920
|
+
if (!pump())
|
|
1921
|
+
break;
|
|
1922
|
+
},
|
|
1923
|
+
content: async (ref) => {
|
|
1924
|
+
const found = contents.get(ref.uri);
|
|
1925
|
+
// A ref nobody registered is the shape a host answers with when the
|
|
1926
|
+
// content has expired, and a viewer has to have something to say about
|
|
1927
|
+
// it other than a blank pane.
|
|
1928
|
+
if (found === undefined)
|
|
1929
|
+
throw new Error(`No content for ${ref.uri}`);
|
|
1930
|
+
return { text: found };
|
|
1931
|
+
},
|
|
1932
|
+
customizations: async () => CUSTOMIZATIONS.map((entry) => ({ ...entry })),
|
|
1933
|
+
// The scripted harness contributes the same things whether or not a
|
|
1934
|
+
// session exists, which is what makes it a script.
|
|
1935
|
+
harnessCommands: async () => CUSTOMIZATIONS
|
|
1936
|
+
.filter((entry) => entry.kind === 'skill' || entry.kind === 'prompt')
|
|
1937
|
+
.map((entry) => ({ ...entry })),
|
|
1938
|
+
setCustomizationEnabled: (uri, id, enabled) => {
|
|
1939
|
+
const found = CUSTOMIZATIONS.find((entry) => entry.id === id);
|
|
1940
|
+
if (!found)
|
|
1941
|
+
return;
|
|
1942
|
+
found.enabled = enabled;
|
|
1943
|
+
// A container carries its children with it, the way the host's own
|
|
1944
|
+
// resolution does - turning a plugin off turns off everything it
|
|
1945
|
+
// brought, whatever each child's own flag says.
|
|
1946
|
+
if (found.kind === 'plugin' || found.kind === 'directory') {
|
|
1947
|
+
for (const child of CUSTOMIZATIONS) {
|
|
1948
|
+
if (child.from === found.name)
|
|
1949
|
+
child.enabled = enabled;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
emit(uri, { type: 'status', status: statusOf(uri) });
|
|
1953
|
+
},
|
|
1954
|
+
detail: async (uri) => {
|
|
1955
|
+
const chat = chats.get(uri) ?? null;
|
|
1956
|
+
const history = turns.get(uri) ?? [];
|
|
1957
|
+
const last = [...history, ...(active.get(uri) ? [active.get(uri)] : [])]
|
|
1958
|
+
.filter((turn) => turn.model).pop();
|
|
1959
|
+
return {
|
|
1960
|
+
resource: uri,
|
|
1961
|
+
chat,
|
|
1962
|
+
chats: chatsOf(uri),
|
|
1963
|
+
lifecycle: summaries.has(uri) ? 'ready' : 'creating',
|
|
1964
|
+
config: {
|
|
1965
|
+
properties: CONFIG,
|
|
1966
|
+
values: { permissionMode: 'default', isolation: 'workspace', ...(configs.get(uri) ?? {}) },
|
|
1967
|
+
},
|
|
1968
|
+
// The id a turn named, resolved against the catalogue - which is what
|
|
1969
|
+
// the live host does, and a fixture that answered a bare id would be
|
|
1970
|
+
// one where the screens were never asked to resolve anything.
|
|
1971
|
+
...(last?.model ? { model: modelRow(last.model.id) } : {}),
|
|
1972
|
+
...(summaries.get(uri)?.activity ? { activity: summaries.get(uri)?.activity } : {}),
|
|
1973
|
+
};
|
|
1974
|
+
},
|
|
1975
|
+
config: async (uri) => ({
|
|
1976
|
+
properties: CONFIG,
|
|
1977
|
+
values: { permissionMode: 'default', isolation: 'workspace', ...(configs.get(uri) ?? {}) },
|
|
1978
|
+
}),
|
|
1979
|
+
setConfig: (uri, key, value) => {
|
|
1980
|
+
// One key, merged. Writing the whole object back is how a value another
|
|
1981
|
+
// client changed a moment ago is quietly reverted.
|
|
1982
|
+
configs.set(uri, { ...(configs.get(uri) ?? {}), [key]: value });
|
|
1983
|
+
},
|
|
1984
|
+
pump,
|
|
1985
|
+
drain: (limit = 5000) => {
|
|
1986
|
+
for (let i = 0; i < limit; i++)
|
|
1987
|
+
if (!pump())
|
|
1988
|
+
break;
|
|
1989
|
+
},
|
|
1990
|
+
pending: () => script.length,
|
|
1991
|
+
dispatched: () => [...sent],
|
|
1992
|
+
invoked: () => [...invoked],
|
|
1993
|
+
/** What is being watched, so a screen closing can be seen to release it. */
|
|
1994
|
+
watching: () => [...watched],
|
|
1995
|
+
};
|
|
1996
|
+
}
|