@volter/supercode-frontend-vscode 0.1.11
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/NOTICE.md +19 -0
- package/README.md +293 -0
- package/dist/bindings.d.ts +7 -0
- package/dist/bindings.js +5 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.js +5 -0
- package/dist/context.d.ts +6 -0
- package/dist/context.js +14 -0
- package/dist/credential.d.ts +17 -0
- package/dist/credential.js +53 -0
- package/dist/extension.d.ts +39 -0
- package/dist/extension.js +1131 -0
- package/dist/host-controls.d.ts +58 -0
- package/dist/host-controls.js +66 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/native-session.d.ts +6 -0
- package/dist/native-session.js +41 -0
- package/dist/proposals.d.ts +24 -0
- package/dist/proposals.js +54 -0
- package/dist/session.d.ts +154 -0
- package/dist/session.js +431 -0
- package/dist/view.d.ts +176 -0
- package/dist/view.js +717 -0
- package/media/anthropic.svg +1 -0
- package/media/google.svg +1 -0
- package/media/icon.png +0 -0
- package/media/openai.svg +1 -0
- package/package.json +200 -0
- package/scripts/pack-extension.mjs +63 -0
- package/vendor/supercode-frontend/README.md +22 -0
- package/vendor/supercode-frontend/client.d.mts +106 -0
- package/vendor/supercode-frontend/client.mjs +300 -0
- package/vendor/supercode-frontend/contract.json +289 -0
- package/vendor/supercode-frontend/generated-client.d.mts +18 -0
- package/vendor/supercode-frontend/generated-client.mjs +20 -0
- package/vendor/supercode-frontend/generated.d.mts +146 -0
- package/vendor/supercode-frontend/generated.mjs +19 -0
- package/vendor/supercode-frontend/package.json +20 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/** The request this attachment tried to answer is no longer open. */
|
|
2
|
+
export class RequestNotPendingError extends Error {
|
|
3
|
+
requestId;
|
|
4
|
+
constructor(requestId) {
|
|
5
|
+
super(`request #${requestId} is no longer pending`);
|
|
6
|
+
this.name = 'RequestNotPendingError';
|
|
7
|
+
this.requestId = requestId;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class EventSequenceGapError extends Error {
|
|
11
|
+
constructor(expected, actual) {
|
|
12
|
+
super(`frontend event sequence gap: expected ${expected}, received ${actual}`);
|
|
13
|
+
this.name = 'EventSequenceGapError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The lifecycle kinds that end one submitted turn, from `submit_claimed`. */
|
|
17
|
+
export const TURN_TERMINAL_KINDS = Object.freeze(['turn_succeeded', 'turn_failed', 'turn_interrupted']);
|
|
18
|
+
/**
|
|
19
|
+
* The runtime hands ONE controller a lease and expires it
|
|
20
|
+
* (`crates/harness/src/runtime_lease.rs`); an interactive attachment must hold
|
|
21
|
+
* it, so these two error names are a take-control-and-retry, never a failure
|
|
22
|
+
* the view reports.
|
|
23
|
+
*/
|
|
24
|
+
export const LEASE_ERROR_NAMES = ['controller_required', 'lease_expired'];
|
|
25
|
+
/**
|
|
26
|
+
* ONLY the terminal kinds idle the descriptor. `turn_completed` is
|
|
27
|
+
* `AgentEvent::TurnCompleted` — one model round-trip, fired before a tool even
|
|
28
|
+
* runs — so treating it as idle ends the turn the moment the model first stops
|
|
29
|
+
* talking, which is exactly when an approval is about to be raised.
|
|
30
|
+
*/
|
|
31
|
+
const IDLE_KINDS = new Set(TURN_TERMINAL_KINDS);
|
|
32
|
+
const BUSY_KINDS = new Set(['turn_started', 'user_message']);
|
|
33
|
+
/**
|
|
34
|
+
* One attachment to one already-running Volter Harness SDK runtime: cursor-safe
|
|
35
|
+
* event projection, permission narrowing, and the semantic actions the runtime
|
|
36
|
+
* advertises. It owns no agent, no model, no tool and no session file.
|
|
37
|
+
*/
|
|
38
|
+
export class VscodeFrontendSession {
|
|
39
|
+
id;
|
|
40
|
+
baseUrl;
|
|
41
|
+
client;
|
|
42
|
+
permissions;
|
|
43
|
+
descriptor = null;
|
|
44
|
+
history = [];
|
|
45
|
+
historyCursor = 0;
|
|
46
|
+
cursor = 0;
|
|
47
|
+
/**
|
|
48
|
+
* Every event this attachment has seen, in order — NOT just the last
|
|
49
|
+
* snapshot's. A re-attach asks `afterSequence: cursor`, so its `replay` is
|
|
50
|
+
* empty by design; overwriting with it threw away the conversation a hosted
|
|
51
|
+
* harness keeps there (`HostedHarnessRuntime::attach` returns no canonical
|
|
52
|
+
* history at all). Bounded, because a long session is a lot of events.
|
|
53
|
+
*/
|
|
54
|
+
replay = [];
|
|
55
|
+
pendingRequests = new Map();
|
|
56
|
+
/**
|
|
57
|
+
* Requests this attachment has seen resolved. `request_resolved` deletes the
|
|
58
|
+
* pending entry while it is being projected, so without this the resolution
|
|
59
|
+
* could only say "request #1" — it would have lost the very thing it needs to
|
|
60
|
+
* name. Bounded, because a long session resolves a lot of them.
|
|
61
|
+
*/
|
|
62
|
+
resolvedRequests = new Map();
|
|
63
|
+
connection = 'disconnected';
|
|
64
|
+
/**
|
|
65
|
+
* `frontend.v2`'s descriptor and history carry NO timestamp, so the only
|
|
66
|
+
* true times this frontend knows are its own: when this window attached, and
|
|
67
|
+
* the turn boundaries it watched go by. The editor's session list always
|
|
68
|
+
* reads `timing.created` and renders 1970 when it is absent, so these are
|
|
69
|
+
* what it gets — never a zero dressed up as a date.
|
|
70
|
+
*/
|
|
71
|
+
attachedAtMs = null;
|
|
72
|
+
lastTurnStartedMs = null;
|
|
73
|
+
lastTurnEndedMs = null;
|
|
74
|
+
#subscribed = false;
|
|
75
|
+
#following = null;
|
|
76
|
+
#heartbeat = null;
|
|
77
|
+
#resyncing = false;
|
|
78
|
+
#listeners = new Set();
|
|
79
|
+
#changeObservers = new Set();
|
|
80
|
+
#eventListener = (event) => {
|
|
81
|
+
this.applyEvent(event);
|
|
82
|
+
};
|
|
83
|
+
#disconnectListener = () => {
|
|
84
|
+
if (this.connection === 'connected')
|
|
85
|
+
this.connection = 'disconnected';
|
|
86
|
+
this.notify();
|
|
87
|
+
};
|
|
88
|
+
constructor(id, baseUrl, client, permissions = ['observe']) {
|
|
89
|
+
this.id = id;
|
|
90
|
+
this.baseUrl = baseUrl;
|
|
91
|
+
this.client = client;
|
|
92
|
+
this.permissions = new Set(permissions);
|
|
93
|
+
if (!this.permissions.has('observe'))
|
|
94
|
+
throw new TypeError('a frontend attachment requires observe');
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Take the attachment snapshot. The generated client opens the SSE stream
|
|
98
|
+
* BEFORE the snapshot RPC, so no event can fall between the two.
|
|
99
|
+
*/
|
|
100
|
+
async attach(limit = 1000) {
|
|
101
|
+
const snapshot = await this.client.attach(limit, { afterSequence: this.cursor });
|
|
102
|
+
const staged = {
|
|
103
|
+
descriptor: structuredClone(snapshot.descriptor),
|
|
104
|
+
pendingRequests: new Map([...this.pendingRequests].map(([id, request]) => [id, structuredClone(request)])),
|
|
105
|
+
cursor: Math.max(this.cursor, snapshot.history_cursor),
|
|
106
|
+
};
|
|
107
|
+
// Validate and project the whole attachment before any observable field
|
|
108
|
+
// moves or a live listener is registered.
|
|
109
|
+
for (const event of snapshot.replay)
|
|
110
|
+
this.projectEvent(staged, event);
|
|
111
|
+
// A request seen in the REPLAY is transcript, not an open ask. The runtime
|
|
112
|
+
// re-sequences its request history at every turn boundary
|
|
113
|
+
// (`compact_frontend_request_history`), and that compaction can keep a
|
|
114
|
+
// `request` whose `request_resolved` is gone — measured on a live runtime,
|
|
115
|
+
// whose replay read `[[12,"request",1],[13,"turn_succeeded"]]` for a
|
|
116
|
+
// request it had long since finished with. Answering one of those sends a
|
|
117
|
+
// dead id. An approval can only be outstanding while a turn is running,
|
|
118
|
+
// because `decide_approval` blocks the agent loop, so an idle runtime has
|
|
119
|
+
// none no matter what its replay still carries.
|
|
120
|
+
if (staged.descriptor?.turn_state !== 'busy')
|
|
121
|
+
staged.pendingRequests.clear();
|
|
122
|
+
this.descriptor = staged.descriptor;
|
|
123
|
+
this.history = structuredClone(snapshot.history);
|
|
124
|
+
this.historyCursor = snapshot.history_cursor;
|
|
125
|
+
this.appendTranscript(structuredClone(snapshot.replay));
|
|
126
|
+
this.pendingRequests = staged.pendingRequests;
|
|
127
|
+
this.cursor = staged.cursor;
|
|
128
|
+
this.connection = 'connected';
|
|
129
|
+
this.attachedAtMs ??= Date.now();
|
|
130
|
+
if (!this.#subscribed) {
|
|
131
|
+
this.client.on('event', this.#eventListener);
|
|
132
|
+
this.client.on('disconnect', this.#disconnectListener);
|
|
133
|
+
this.#subscribed = true;
|
|
134
|
+
}
|
|
135
|
+
this.notify();
|
|
136
|
+
return snapshot;
|
|
137
|
+
}
|
|
138
|
+
/** Start consuming the already-open live stream. Resolves when it ends. */
|
|
139
|
+
follow() {
|
|
140
|
+
if (!this.#following) {
|
|
141
|
+
this.#following = this.client.startEvents({ afterSequence: this.cursor }).finally(() => {
|
|
142
|
+
this.#following = null;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return this.#following;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Hold the controller lease for as long as this attachment lasts. Observers
|
|
149
|
+
* never call this; only a permission that can submit needs the lease.
|
|
150
|
+
*/
|
|
151
|
+
async takeControlAndHold() {
|
|
152
|
+
if (!this.permissions.has('interact'))
|
|
153
|
+
return;
|
|
154
|
+
const lease = await this.client.takeControl();
|
|
155
|
+
this.startHeartbeat(lease.lease_ttl_ms);
|
|
156
|
+
}
|
|
157
|
+
startHeartbeat(ttlMs) {
|
|
158
|
+
this.stopHeartbeat();
|
|
159
|
+
const every = Math.max(1000, Math.floor((Number.isFinite(ttlMs) ? ttlMs : 30000) / 3));
|
|
160
|
+
this.#heartbeat = setInterval(() => {
|
|
161
|
+
void this.client.heartbeat().catch(() => {
|
|
162
|
+
// A lost lease is re-taken on the next action; a failing heartbeat is
|
|
163
|
+
// never an error this view reports.
|
|
164
|
+
});
|
|
165
|
+
}, every);
|
|
166
|
+
}
|
|
167
|
+
stopHeartbeat() {
|
|
168
|
+
if (this.#heartbeat)
|
|
169
|
+
clearInterval(this.#heartbeat);
|
|
170
|
+
this.#heartbeat = null;
|
|
171
|
+
}
|
|
172
|
+
dispose() {
|
|
173
|
+
this.stopHeartbeat();
|
|
174
|
+
this.client.stopEvents();
|
|
175
|
+
if (this.#subscribed) {
|
|
176
|
+
this.client.off('event', this.#eventListener);
|
|
177
|
+
this.client.off('disconnect', this.#disconnectListener);
|
|
178
|
+
this.#subscribed = false;
|
|
179
|
+
}
|
|
180
|
+
this.#listeners.clear();
|
|
181
|
+
this.#changeObservers.clear();
|
|
182
|
+
}
|
|
183
|
+
/** Keep the transcript in order and free of the duplicates a re-attach brings. */
|
|
184
|
+
appendTranscript(events) {
|
|
185
|
+
const seen = new Set(this.replay.map((event) => event.sequence));
|
|
186
|
+
for (const event of events) {
|
|
187
|
+
if (seen.has(event.sequence))
|
|
188
|
+
continue;
|
|
189
|
+
seen.add(event.sequence);
|
|
190
|
+
this.replay.push(event);
|
|
191
|
+
}
|
|
192
|
+
this.replay.sort((left, right) => left.sequence - right.sequence);
|
|
193
|
+
if (this.replay.length > 2000)
|
|
194
|
+
this.replay = this.replay.slice(-2000);
|
|
195
|
+
}
|
|
196
|
+
applyEvent(event) {
|
|
197
|
+
let applied;
|
|
198
|
+
try {
|
|
199
|
+
applied = this.projectEvent(this, event);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (!(error instanceof EventSequenceGapError))
|
|
203
|
+
throw error;
|
|
204
|
+
// A gap is what the cursor exists for, not a reason to die. Throwing out
|
|
205
|
+
// of the live listener would tear the subscription down and leave every
|
|
206
|
+
// in-flight turn waiting for a terminal event that can no longer arrive.
|
|
207
|
+
// Re-take the snapshot from the current cursor instead; the generated
|
|
208
|
+
// client reuses the open stream, so this is a resync, not a reconnect.
|
|
209
|
+
this.resync();
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
if (!applied)
|
|
213
|
+
return false;
|
|
214
|
+
this.appendTranscript([event]);
|
|
215
|
+
for (const listener of [...this.#listeners]) {
|
|
216
|
+
try {
|
|
217
|
+
listener(event);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// A view that throws never stops the projection.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
this.notify();
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
/** Re-take the attachment snapshot after a sequence gap, once at a time. */
|
|
227
|
+
resync() {
|
|
228
|
+
if (this.#resyncing)
|
|
229
|
+
return;
|
|
230
|
+
this.#resyncing = true;
|
|
231
|
+
void this.attach()
|
|
232
|
+
.catch(() => {
|
|
233
|
+
// An unreachable runtime is reported by the next action, not here.
|
|
234
|
+
})
|
|
235
|
+
.finally(() => {
|
|
236
|
+
this.#resyncing = false;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/** Subscribe to every event this session projects, from now on. */
|
|
240
|
+
onEvent(listener) {
|
|
241
|
+
this.#listeners.add(listener);
|
|
242
|
+
return () => this.#listeners.delete(listener);
|
|
243
|
+
}
|
|
244
|
+
onChange(observer) {
|
|
245
|
+
this.#changeObservers.add(observer);
|
|
246
|
+
return () => this.#changeObservers.delete(observer);
|
|
247
|
+
}
|
|
248
|
+
projectEvent(state, event) {
|
|
249
|
+
if (event.sequence <= state.cursor)
|
|
250
|
+
return false;
|
|
251
|
+
if (event.sequence !== state.cursor + 1) {
|
|
252
|
+
throw new EventSequenceGapError(state.cursor + 1, event.sequence);
|
|
253
|
+
}
|
|
254
|
+
state.cursor = event.sequence;
|
|
255
|
+
const payload = (event.payload ?? {});
|
|
256
|
+
if (event.kind === 'request' && payload.type === 'request') {
|
|
257
|
+
const request = payload.request;
|
|
258
|
+
if (request && typeof request.id === 'number')
|
|
259
|
+
state.pendingRequests.set(request.id, structuredClone(request));
|
|
260
|
+
}
|
|
261
|
+
if (event.kind === 'request_resolved' && typeof payload.request_id === 'number') {
|
|
262
|
+
const resolved = state.pendingRequests.get(payload.request_id);
|
|
263
|
+
if (resolved) {
|
|
264
|
+
this.resolvedRequests.set(payload.request_id, resolved);
|
|
265
|
+
while (this.resolvedRequests.size > 50) {
|
|
266
|
+
this.resolvedRequests.delete(this.resolvedRequests.keys().next().value);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
state.pendingRequests.delete(payload.request_id);
|
|
270
|
+
}
|
|
271
|
+
if (state === this) {
|
|
272
|
+
if (BUSY_KINDS.has(event.kind))
|
|
273
|
+
this.lastTurnStartedMs = Date.now();
|
|
274
|
+
else if (TURN_TERMINAL_KINDS.includes(event.kind))
|
|
275
|
+
this.lastTurnEndedMs = Date.now();
|
|
276
|
+
}
|
|
277
|
+
if (state.descriptor && IDLE_KINDS.has(event.kind)) {
|
|
278
|
+
state.descriptor = { ...state.descriptor, turn_state: 'idle' };
|
|
279
|
+
}
|
|
280
|
+
else if (state.descriptor && BUSY_KINDS.has(event.kind)) {
|
|
281
|
+
state.descriptor = { ...state.descriptor, turn_state: 'busy' };
|
|
282
|
+
}
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
/** What this attachment may do: permission AND the descriptor's own advertisement. */
|
|
286
|
+
actions() {
|
|
287
|
+
const advertised = this.descriptor?.actions;
|
|
288
|
+
const interactive = this.permissions.has('interact');
|
|
289
|
+
const busy = this.descriptor?.turn_state === 'busy';
|
|
290
|
+
return {
|
|
291
|
+
submit: Boolean(advertised?.submit && interactive && !busy),
|
|
292
|
+
steer: Boolean(advertised?.steer && interactive && busy),
|
|
293
|
+
interrupt: Boolean(advertised?.interrupt && interactive && busy),
|
|
294
|
+
respond: Boolean(advertised?.respond && this.permissions.has('approve') && this.pendingRequests.size > 0),
|
|
295
|
+
detach: Boolean(advertised?.detach && this.permissions.has('observe')),
|
|
296
|
+
close: Boolean(advertised?.close && this.permissions.has('terminate')),
|
|
297
|
+
takeover: Boolean(this.descriptor && interactive && this.connection === 'connected'),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** Run one controller action, re-taking an expired lease exactly once. */
|
|
301
|
+
async withControl(action) {
|
|
302
|
+
try {
|
|
303
|
+
return await action();
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
const name = error?.name ?? '';
|
|
307
|
+
if (!LEASE_ERROR_NAMES.includes(name) || !this.permissions.has('interact'))
|
|
308
|
+
throw error;
|
|
309
|
+
const lease = await this.client.takeControl();
|
|
310
|
+
this.startHeartbeat(lease.lease_ttl_ms);
|
|
311
|
+
return action();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async submit(prompt) {
|
|
315
|
+
this.require('submit');
|
|
316
|
+
if (prompt.length === 0)
|
|
317
|
+
throw new TypeError('prompt must not be empty');
|
|
318
|
+
await this.withControl(() => this.client.sendInput(prompt));
|
|
319
|
+
}
|
|
320
|
+
async steer(prompt) {
|
|
321
|
+
this.require('steer');
|
|
322
|
+
if (prompt.length === 0)
|
|
323
|
+
throw new TypeError('prompt must not be empty');
|
|
324
|
+
await this.withControl(() => this.client.steer(prompt));
|
|
325
|
+
}
|
|
326
|
+
async interrupt() {
|
|
327
|
+
this.require('interrupt');
|
|
328
|
+
await this.withControl(() => this.client.interrupt());
|
|
329
|
+
}
|
|
330
|
+
async respond(response) {
|
|
331
|
+
this.require('respond');
|
|
332
|
+
if (!this.pendingRequests.has(response.request_id))
|
|
333
|
+
throw new RequestNotPendingError(response.request_id);
|
|
334
|
+
try {
|
|
335
|
+
await this.withControl(() => this.client.respond(response));
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
// The runtime is the authority on what is still open. When it says it is
|
|
339
|
+
// not, this attachment's view of it was stale — drop it rather than let
|
|
340
|
+
// the same dead id be offered again.
|
|
341
|
+
if (error?.name === 'not_found' || /not pending|unknown request/i.test(String(error?.message))) {
|
|
342
|
+
this.pendingRequests.delete(response.request_id);
|
|
343
|
+
this.notify();
|
|
344
|
+
throw new RequestNotPendingError(response.request_id);
|
|
345
|
+
}
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async detach() {
|
|
350
|
+
this.require('detach');
|
|
351
|
+
await this.client.detach();
|
|
352
|
+
this.connection = 'disconnected';
|
|
353
|
+
this.notify();
|
|
354
|
+
}
|
|
355
|
+
async close() {
|
|
356
|
+
this.require('close');
|
|
357
|
+
await this.client.close();
|
|
358
|
+
this.connection = 'closed';
|
|
359
|
+
this.notify();
|
|
360
|
+
}
|
|
361
|
+
async takeover() {
|
|
362
|
+
this.require('takeover');
|
|
363
|
+
const lease = await this.client.takeControl();
|
|
364
|
+
this.startHeartbeat(lease.lease_ttl_ms);
|
|
365
|
+
this.notify();
|
|
366
|
+
}
|
|
367
|
+
async heartbeat() {
|
|
368
|
+
if (this.connection !== 'connected')
|
|
369
|
+
return null;
|
|
370
|
+
return this.client.heartbeat();
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Submit one prompt and feed every event of the resulting turn to `onEvent`,
|
|
374
|
+
* resolving when the runtime reports the turn finished. The subscription is
|
|
375
|
+
* registered BEFORE the prompt is sent, so no early delta is lost.
|
|
376
|
+
*/
|
|
377
|
+
async runTurn(prompt, onEvent, options = {}) {
|
|
378
|
+
let settle;
|
|
379
|
+
let fail;
|
|
380
|
+
const finished = new Promise((resolve, reject) => {
|
|
381
|
+
settle = resolve;
|
|
382
|
+
fail = reject;
|
|
383
|
+
});
|
|
384
|
+
const unsubscribe = this.onEvent((event) => {
|
|
385
|
+
try {
|
|
386
|
+
onEvent(event);
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
if (TURN_TERMINAL_KINDS.includes(event.kind))
|
|
390
|
+
settle?.(event);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
// A turn that never sees its terminal event must still end: the editor
|
|
394
|
+
// holds the chat response open until this handler resolves, and a frontend
|
|
395
|
+
// that hangs one is worse than one that ends it a beat early. The
|
|
396
|
+
// descriptor going idle after the submit is the second, independent signal.
|
|
397
|
+
let sawBusy = false;
|
|
398
|
+
const unwatch = this.onChange(() => {
|
|
399
|
+
const state = this.descriptor?.turn_state;
|
|
400
|
+
if (state === 'busy')
|
|
401
|
+
sawBusy = true;
|
|
402
|
+
else if (sawBusy && state === 'idle')
|
|
403
|
+
settle?.(null);
|
|
404
|
+
});
|
|
405
|
+
options.onCancel?.(() => {
|
|
406
|
+
void this.interrupt().catch((error) => fail?.(error));
|
|
407
|
+
});
|
|
408
|
+
try {
|
|
409
|
+
await this.submit(prompt);
|
|
410
|
+
return await finished;
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
unsubscribe();
|
|
414
|
+
unwatch();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
require(action) {
|
|
418
|
+
if (!this.actions()[action])
|
|
419
|
+
throw new Error(`frontend action ${action} is unavailable`);
|
|
420
|
+
}
|
|
421
|
+
notify() {
|
|
422
|
+
for (const observer of [...this.#changeObservers]) {
|
|
423
|
+
try {
|
|
424
|
+
observer();
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// An observer that throws never stops the projection.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
package/dist/view.d.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { ChatMessage, FrontendEvent, FrontendRequest, FrontendResponse } from '../vendor/supercode-frontend/generated.mjs';
|
|
2
|
+
import type { ProposalName } from './proposals.js';
|
|
3
|
+
/**
|
|
4
|
+
* The bounded projection this presentation package renders. Mirrors
|
|
5
|
+
* `@volter/supercode-frontend-browser`'s rule: the view is size-bounded
|
|
6
|
+
* and says so when it omits something; the runtime's own history is unchanged
|
|
7
|
+
* and remains authoritative.
|
|
8
|
+
*/
|
|
9
|
+
export declare const VSCODE_CHAT_LIMITS: Readonly<{
|
|
10
|
+
partCharacters: number;
|
|
11
|
+
titleCharacters: 256;
|
|
12
|
+
opaqueCharacters: number;
|
|
13
|
+
historyTurns: 500;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* What one answer button sends back to the runtime.
|
|
17
|
+
*
|
|
18
|
+
* MEASURED: `ChatResponseConfirmationPart` is the wrong mechanism here. The
|
|
19
|
+
* editor answers a confirmation by sending a NEW chat request carrying the
|
|
20
|
+
* part's data (`chatConfirmationContentPart`), and a supercode approval is
|
|
21
|
+
* raised WHILE the turn is still running — `decide_approval` in
|
|
22
|
+
* crates/harness/src/server.rs blocks the agent loop until it is answered — so
|
|
23
|
+
* the editor refuses that second request and the click does nothing. An
|
|
24
|
+
* answer is therefore a COMMAND button: it runs inside the live turn, it is
|
|
25
|
+
* stable API (no proposal), and each button carries its own answer instead of
|
|
26
|
+
* the one payload a confirmation splits across accepted/rejected.
|
|
27
|
+
*/
|
|
28
|
+
export interface ChatAnswer {
|
|
29
|
+
connectionId: string;
|
|
30
|
+
requestId: number;
|
|
31
|
+
response: FrontendResponse;
|
|
32
|
+
/** Ask for the content this response should carry before sending it. */
|
|
33
|
+
promptForContent?: boolean;
|
|
34
|
+
label: string;
|
|
35
|
+
}
|
|
36
|
+
export type ChatPart = {
|
|
37
|
+
part: 'markdown';
|
|
38
|
+
value: string;
|
|
39
|
+
} | {
|
|
40
|
+
part: 'progress';
|
|
41
|
+
value: string;
|
|
42
|
+
} | {
|
|
43
|
+
part: 'warning';
|
|
44
|
+
value: string;
|
|
45
|
+
} | {
|
|
46
|
+
part: 'thinking';
|
|
47
|
+
value: string;
|
|
48
|
+
}
|
|
49
|
+
/** A project-relative path the runtime's own payload named. */
|
|
50
|
+
| {
|
|
51
|
+
part: 'reference';
|
|
52
|
+
path: string;
|
|
53
|
+
} | {
|
|
54
|
+
part: 'codeblock';
|
|
55
|
+
uri: string;
|
|
56
|
+
value: string;
|
|
57
|
+
language: string;
|
|
58
|
+
} | {
|
|
59
|
+
part: 'tool';
|
|
60
|
+
toolName: string;
|
|
61
|
+
toolCallId: string;
|
|
62
|
+
invocationMessage: string;
|
|
63
|
+
inputText?: string;
|
|
64
|
+
pastTenseMessage?: string;
|
|
65
|
+
isError?: boolean;
|
|
66
|
+
isComplete: boolean;
|
|
67
|
+
resultText?: string;
|
|
68
|
+
} | {
|
|
69
|
+
part: 'answers';
|
|
70
|
+
title: string;
|
|
71
|
+
message: string;
|
|
72
|
+
answers: ChatAnswer[];
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* One rendered part plus the API proposal it needs and what this view renders
|
|
76
|
+
* instead when the host did not grant it. README.md's proposal table is this
|
|
77
|
+
* data; nothing else decides which part needs which proposal.
|
|
78
|
+
*/
|
|
79
|
+
export interface ProjectedPart {
|
|
80
|
+
emit: ChatPart;
|
|
81
|
+
needs?: ProposalName;
|
|
82
|
+
fallback?: ChatPart[];
|
|
83
|
+
}
|
|
84
|
+
/** Markdown-safe rendering of an arbitrary JSON payload, bounded. */
|
|
85
|
+
export declare function opaqueText(value: unknown, limit?: number): string;
|
|
86
|
+
/** A fenced block that its own content cannot break out of. */
|
|
87
|
+
export declare function fence(body: string, language?: string): string;
|
|
88
|
+
/**
|
|
89
|
+
* The files a unified diff says it touched, read from its own `+++ b/<path>`
|
|
90
|
+
* headers. Nothing else is derived from the diff: this frontend never rebuilds
|
|
91
|
+
* edits from it (see the `diff_updated` arm).
|
|
92
|
+
*/
|
|
93
|
+
export declare function diffPaths(diff: string): string[];
|
|
94
|
+
/**
|
|
95
|
+
* The one-line subject a tool call is about, read from its own arguments.
|
|
96
|
+
* The editor renders `invocationMessage` verbatim, so a bare tool name is a
|
|
97
|
+
* wasted row; `write_file · hello.txt` says what it did.
|
|
98
|
+
*/
|
|
99
|
+
export declare function toolSubject(argumentsText: string): string | undefined;
|
|
100
|
+
/** The summary line this view shows for a request payload. */
|
|
101
|
+
export declare function requestSummary(request: FrontendRequest): string;
|
|
102
|
+
/** The answers this view offers for a request, one button each. */
|
|
103
|
+
export declare function requestAnswers(request: FrontendRequest, connectionId: string): ChatAnswer[];
|
|
104
|
+
/**
|
|
105
|
+
* The typed-reply path, for a request that is still pending when the turn is
|
|
106
|
+
* NOT running: the person types the answer as the next message.
|
|
107
|
+
*/
|
|
108
|
+
export declare function typedAnswer(request: FrontendRequest, reply: string): FrontendResponse;
|
|
109
|
+
/** The decisions a runtime said its harness accepts, when it said so. */
|
|
110
|
+
export declare function acceptedDecisions(request: FrontendRequest): string[] | undefined;
|
|
111
|
+
/** True when this request asks for content a button click cannot carry. */
|
|
112
|
+
export declare function requestNeedsTypedReply(request: FrontendRequest): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* The resolution of a request as a sentence, built from the response's own
|
|
115
|
+
* fields and the request it answers — never the raw JSON, which says the same
|
|
116
|
+
* thing in a form only this protocol's authors can read.
|
|
117
|
+
*/
|
|
118
|
+
export declare function resolutionSentence(response: FrontendResponse | undefined, request: FrontendRequest | undefined, requestId: number): string;
|
|
119
|
+
/** `write_file on hello.txt`, read from the request's own payload. */
|
|
120
|
+
export declare function requestSubject(request: FrontendRequest): string | undefined;
|
|
121
|
+
export interface ProjectionContext {
|
|
122
|
+
/** Identifies which attached runtime a confirmation click must answer. */
|
|
123
|
+
connectionId: string;
|
|
124
|
+
/** Render the runtime's echo of a prompt (history replay does; live does not). */
|
|
125
|
+
echoOwnPrompts?: boolean;
|
|
126
|
+
/** The request a `request_resolved` answers, so its sentence can name it. */
|
|
127
|
+
pendingRequest?: (requestId: number) => FrontendRequest | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* Whether the runtime's own descriptor advertises `respond`. A HOSTED
|
|
130
|
+
* harness runtime sets `actions.respond: false` — the portable contract
|
|
131
|
+
* cannot carry that harness's native response envelope, so its owner/editor
|
|
132
|
+
* answers through `harness.v1.approvals.resolve` and this view may only
|
|
133
|
+
* OBSERVE the request (`crates/harness/src/runtime/hosted.rs`). A control is
|
|
134
|
+
* rendered only for an action the descriptor advertises, so the answer
|
|
135
|
+
* buttons are absent rather than dead.
|
|
136
|
+
*/
|
|
137
|
+
canRespond?: boolean;
|
|
138
|
+
}
|
|
139
|
+
/** Pending requests belong to the live runtime, even when saved transcript
|
|
140
|
+
* history predates them. Rehydrate their controls before waiting for new events. */
|
|
141
|
+
export declare function projectPendingRequests(requests: Iterable<FrontendRequest>, context: ProjectionContext): ProjectedPart[];
|
|
142
|
+
/**
|
|
143
|
+
* The event to chat-response-part mapping. Pure: every input is the runtime's
|
|
144
|
+
* own sequenced event, every output describes a VS Code chat part. Each arm
|
|
145
|
+
* names the producer it reads.
|
|
146
|
+
*/
|
|
147
|
+
export declare function projectEvent(event: FrontendEvent, context: ProjectionContext): ProjectedPart[];
|
|
148
|
+
/** Replace every part whose proposal the host did not grant with its fallback. */
|
|
149
|
+
export declare function resolveParts(parts: readonly ProjectedPart[], granted: ReadonlySet<string>): ChatPart[];
|
|
150
|
+
/**
|
|
151
|
+
* The prompt of the marker turn that carries runtime history with no user turn
|
|
152
|
+
* of its own. It names itself; nothing here is presented as a person's words.
|
|
153
|
+
*/
|
|
154
|
+
export declare const HISTORY_MARKER_PROMPT = "\u27E8supercode runtime history\u27E9";
|
|
155
|
+
export type TurnRole = 'user' | 'assistant';
|
|
156
|
+
export interface ProjectedTurn {
|
|
157
|
+
role: TurnRole;
|
|
158
|
+
/** The prompt text for a user turn. */
|
|
159
|
+
prompt?: string;
|
|
160
|
+
/** Rendered parts for an assistant turn. */
|
|
161
|
+
parts?: ChatPart[];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* A HOSTED harness runtime's transcript as chat turns.
|
|
165
|
+
*
|
|
166
|
+
* `HostedHarnessRuntime::attach` returns `history: Vec::new()` and puts the
|
|
167
|
+
* whole conversation in `replay` — the canonical ChatMessage history belongs to
|
|
168
|
+
* supercode's own agent loop, and a hosted harness has none. So a resumed
|
|
169
|
+
* Claude Code session's turns are here or nowhere.
|
|
170
|
+
*/
|
|
171
|
+
export declare function projectReplayTurns(events: readonly FrontendEvent[], granted: ReadonlySet<string>, context: ProjectionContext): ProjectedTurn[];
|
|
172
|
+
/**
|
|
173
|
+
* The attach snapshot's canonical history as chat turns. Bounded: when older
|
|
174
|
+
* turns are omitted the view says so, the way the browser observer does.
|
|
175
|
+
*/
|
|
176
|
+
export declare function projectHistory(history: readonly ChatMessage[], granted: ReadonlySet<string>, pendingToolNames?: ReadonlySet<string>): ProjectedTurn[];
|