@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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/dist/src/ahp/channels.d.ts +146 -0
  4. package/dist/src/ahp/channels.js +423 -0
  5. package/dist/src/ahp/connection.d.ts +656 -0
  6. package/dist/src/ahp/connection.js +1 -0
  7. package/dist/src/ahp/fake.d.ts +69 -0
  8. package/dist/src/ahp/fake.js +1996 -0
  9. package/dist/src/ahp/live.d.ts +160 -0
  10. package/dist/src/ahp/live.js +2641 -0
  11. package/dist/src/ahp/operate.d.ts +36 -0
  12. package/dist/src/ahp/operate.js +56 -0
  13. package/dist/src/ahp/publish.d.ts +39 -0
  14. package/dist/src/ahp/publish.js +370 -0
  15. package/dist/src/ahp/status.d.ts +23 -0
  16. package/dist/src/ahp/status.js +23 -0
  17. package/dist/src/ahp/types.d.ts +745 -0
  18. package/dist/src/ahp/types.js +27 -0
  19. package/dist/src/app.d.ts +54 -0
  20. package/dist/src/app.js +369 -0
  21. package/dist/src/blocks.d.ts +88 -0
  22. package/dist/src/blocks.js +102 -0
  23. package/dist/src/cli/main.d.ts +13 -0
  24. package/dist/src/cli/main.js +1537 -0
  25. package/dist/src/cli/render.d.ts +43 -0
  26. package/dist/src/cli/render.js +91 -0
  27. package/dist/src/config.d.ts +63 -0
  28. package/dist/src/config.js +45 -0
  29. package/dist/src/connect.d.ts +53 -0
  30. package/dist/src/connect.js +69 -0
  31. package/dist/src/control.d.ts +189 -0
  32. package/dist/src/control.js +2241 -0
  33. package/dist/src/diff.d.ts +44 -0
  34. package/dist/src/diff.js +111 -0
  35. package/dist/src/flags.d.ts +28 -0
  36. package/dist/src/flags.js +76 -0
  37. package/dist/src/main.d.ts +10 -0
  38. package/dist/src/main.js +54 -0
  39. package/dist/src/schedule.d.ts +46 -0
  40. package/dist/src/schedule.js +140 -0
  41. package/dist/src/screens.d.ts +79 -0
  42. package/dist/src/screens.js +1322 -0
  43. package/dist/src/state.d.ts +355 -0
  44. package/dist/src/state.js +531 -0
  45. package/dist/src/terminal.d.ts +44 -0
  46. package/dist/src/terminal.js +101 -0
  47. package/dist/src/tui.d.ts +92 -0
  48. package/dist/src/tui.js +372 -0
  49. package/dist/src/view/automations.d.ts +31 -0
  50. package/dist/src/view/automations.js +76 -0
  51. package/dist/src/view/bood/art.d.ts +90 -0
  52. package/dist/src/view/bood/art.js +152 -0
  53. package/dist/src/view/bood/beetle.d.ts +7 -0
  54. package/dist/src/view/bood/beetle.js +123 -0
  55. package/dist/src/view/bood/bunny.d.ts +8 -0
  56. package/dist/src/view/bood/bunny.js +228 -0
  57. package/dist/src/view/bood/cat.d.ts +9 -0
  58. package/dist/src/view/bood/cat.js +358 -0
  59. package/dist/src/view/bood/crab.d.ts +7 -0
  60. package/dist/src/view/bood/crab.js +121 -0
  61. package/dist/src/view/bood/index.d.ts +29 -0
  62. package/dist/src/view/bood/index.js +25 -0
  63. package/dist/src/view/bood/motion.d.ts +178 -0
  64. package/dist/src/view/bood/motion.js +298 -0
  65. package/dist/src/view/bood/owl.d.ts +9 -0
  66. package/dist/src/view/bood/owl.js +285 -0
  67. package/dist/src/view/bood/registry.d.ts +36 -0
  68. package/dist/src/view/bood/registry.js +218 -0
  69. package/dist/src/view/bood/render.d.ts +31 -0
  70. package/dist/src/view/bood/render.js +196 -0
  71. package/dist/src/view/bood/sprite.d.ts +18 -0
  72. package/dist/src/view/bood/sprite.js +130 -0
  73. package/dist/src/view/bood/sprout.d.ts +8 -0
  74. package/dist/src/view/bood/sprout.js +121 -0
  75. package/dist/src/view/bood/types.d.ts +158 -0
  76. package/dist/src/view/bood/types.js +57 -0
  77. package/dist/src/view/bubble.d.ts +75 -0
  78. package/dist/src/view/bubble.js +86 -0
  79. package/dist/src/view/changes.d.ts +35 -0
  80. package/dist/src/view/changes.js +22 -0
  81. package/dist/src/view/composer.d.ts +64 -0
  82. package/dist/src/view/composer.js +192 -0
  83. package/dist/src/view/controls.d.ts +44 -0
  84. package/dist/src/view/controls.js +49 -0
  85. package/dist/src/view/creature.d.ts +69 -0
  86. package/dist/src/view/creature.js +83 -0
  87. package/dist/src/view/customizations.d.ts +28 -0
  88. package/dist/src/view/customizations.js +53 -0
  89. package/dist/src/view/details.d.ts +65 -0
  90. package/dist/src/view/details.js +65 -0
  91. package/dist/src/view/filediff.d.ts +29 -0
  92. package/dist/src/view/filediff.js +24 -0
  93. package/dist/src/view/files.d.ts +31 -0
  94. package/dist/src/view/files.js +39 -0
  95. package/dist/src/view/hitl.d.ts +43 -0
  96. package/dist/src/view/hitl.js +171 -0
  97. package/dist/src/view/icons.d.ts +13 -0
  98. package/dist/src/view/icons.js +71 -0
  99. package/dist/src/view/picker.d.ts +42 -0
  100. package/dist/src/view/picker.js +71 -0
  101. package/dist/src/view/sessionhead.d.ts +41 -0
  102. package/dist/src/view/sessionhead.js +60 -0
  103. package/dist/src/view/sessions.d.ts +34 -0
  104. package/dist/src/view/sessions.js +61 -0
  105. package/dist/src/view/terminal.d.ts +30 -0
  106. package/dist/src/view/terminal.js +32 -0
  107. package/dist/src/view/toolcall.d.ts +27 -0
  108. package/dist/src/view/toolcall.js +48 -0
  109. package/dist/src/view/transcript.d.ts +50 -0
  110. package/dist/src/view/transcript.js +60 -0
  111. 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
+ }