@volter/supercode-frontend-vscode 0.1.58 → 0.1.61

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.
@@ -4,6 +4,12 @@ export class ChatHostControls {
4
4
  credential;
5
5
  state;
6
6
  updating = Promise.resolve();
7
+ #stateListeners = new Set();
8
+ /** Call `listener` with each state the host replies with; returns the unsubscribe. */
9
+ onState(listener) {
10
+ this.#stateListeners.add(listener);
11
+ return () => this.#stateListeners.delete(listener);
12
+ }
7
13
  constructor(baseUrl, credential, state) {
8
14
  this.baseUrl = baseUrl;
9
15
  this.credential = credential;
@@ -37,6 +43,8 @@ export class ChatHostControls {
37
43
  const pending = this.updating.then(async () => {
38
44
  const result = await this.request(path, selection);
39
45
  this.state = result;
46
+ for (const listener of this.#stateListeners)
47
+ listener(result);
40
48
  return result;
41
49
  });
42
50
  this.updating = pending.then(() => undefined, () => undefined);
@@ -70,9 +78,12 @@ export function runtimeIdentity(harness, model, effective) {
70
78
  const harnessName = harnessDisplayName(harness);
71
79
  const knownModel = effective?.model || (model && model !== harness ? model : '');
72
80
  const modelName = effective?.modelName || { sonnet: 'Claude Sonnet', opus: 'Claude Opus', haiku: 'Claude Haiku' }[knownModel] || knownModel;
81
+ // The provider of a display name ("Claude Opus 4.8"), whose mark is media/<provider>.svg (supercode-ui's catalogue);
82
+ // the catalogue's modelProvider reads model ids.
73
83
  const provider = /claude|sonnet|opus|haiku/i.test(modelName) || (!modelName && harness === 'claude-code') ? 'anthropic'
74
84
  : /^(gpt|o[134]|chatgpt)|\/gpt/i.test(modelName) || (!modelName && harness === 'codex') ? 'openai'
75
- : /gemini/i.test(modelName) || (!modelName && harness === 'gemini') ? 'google' : undefined;
85
+ : /gemini/i.test(modelName) || (!modelName && harness === 'gemini') ? 'google'
86
+ : /grok/i.test(modelName) || (!modelName && harness === 'grok') ? 'xai' : undefined;
76
87
  const effort = effective?.effort ? ` · ${effective.effort}` : '';
77
88
  return { name: modelName ? `${modelName}${effort}` : `Model chosen by ${harnessName}`, provider };
78
89
  }
@@ -1,6 +1,6 @@
1
1
  import type { CancellationToken } from 'vscode';
2
2
  import type { FrontendEvent } from '../vendor/supercode-frontend/generated.mjs';
3
3
  import { type VscodeFrontendSession } from './session.js';
4
- export declare function sessionStatus(session: Pick<VscodeFrontendSession, 'descriptor' | 'pendingRequests'>): 'NeedsInput' | 'InProgress' | 'Completed';
4
+ export declare function sessionStatus(session: Pick<VscodeFrontendSession, 'descriptor' | 'pendingRequests' | 'failure'>): 'NeedsInput' | 'Failed' | 'InProgress' | 'Completed';
5
5
  /** Follow a restored turn, including the interval between snapshot and callback. */
6
- export declare function observeActiveResponse(session: Pick<VscodeFrontendSession, 'descriptor' | 'pendingRequests' | 'onEvent' | 'interrupt' | 'actions' | 'replay'>, token: CancellationToken, write: (event: FrontendEvent) => void, onSubscribed?: () => void, afterSequence?: number): Promise<void>;
6
+ export declare function observeActiveResponse(session: Pick<VscodeFrontendSession, 'descriptor' | 'pendingRequests' | 'onEvent' | 'onChange' | 'onFailure' | 'interrupt' | 'actions' | 'replay'>, token: CancellationToken, write: (event: FrontendEvent) => void, onSubscribed?: () => void, afterSequence?: number): Promise<void>;
@@ -1,10 +1,12 @@
1
1
  import { TURN_TERMINAL_KINDS } from './session.js';
2
2
  export function sessionStatus(session) {
3
- return session.pendingRequests.size ? 'NeedsInput' : session.descriptor?.turn_state === 'busy' ? 'InProgress' : 'Completed';
3
+ return session.pendingRequests.size ? 'NeedsInput' : session.failure ? 'Failed' : session.descriptor?.turn_state === 'busy' ? 'InProgress' : 'Completed';
4
4
  }
5
5
  /** Follow a restored turn, including the interval between snapshot and callback. */
6
6
  export async function observeActiveResponse(session, token, write, onSubscribed, afterSequence) {
7
7
  let unsubscribe = () => { };
8
+ let unfail = () => { };
9
+ let unwatch = () => { };
8
10
  let cancellation;
9
11
  try {
10
12
  await new Promise((resolve, reject) => {
@@ -43,6 +45,11 @@ export async function observeActiveResponse(session, token, write, onSubscribed,
43
45
  resolve();
44
46
  };
45
47
  unsubscribe = session.onEvent(consume);
48
+ unfail = session.onFailure(reject);
49
+ unwatch = session.onChange(() => {
50
+ if (!cancelling && session.descriptor?.turn_state === 'idle' && !session.pendingRequests.size)
51
+ resolve();
52
+ });
46
53
  if (afterSequence !== undefined) {
47
54
  for (const event of session.replay)
48
55
  if (event.sequence > afterSequence)
@@ -58,6 +65,8 @@ export async function observeActiveResponse(session, token, write, onSubscribed,
58
65
  }
59
66
  finally {
60
67
  unsubscribe();
68
+ unfail();
69
+ unwatch();
61
70
  cancellation?.dispose();
62
71
  }
63
72
  }
package/dist/session.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { FrontendPermission } from './client.js';
5
5
  * here is a second protocol: every member is `@volter/supercode-frontend`'s.
6
6
  */
7
7
  export interface VscodeRuntimeClient {
8
+ readonly lastSequence?: number;
8
9
  describe(): Promise<FrontendRuntimeDescriptor>;
9
10
  attach(limit?: number, options?: {
10
11
  afterSequence?: number;
@@ -85,6 +86,8 @@ export declare class VscodeFrontendSession {
85
86
  descriptor: FrontendRuntimeDescriptor | null;
86
87
  history: ChatMessage[];
87
88
  historyCursor: number;
89
+ historyEvents: FrontendEvent[];
90
+ historyEventsDropped: number;
88
91
  cursor: number;
89
92
  /**
90
93
  * Every event this attachment has seen, in order — NOT just the last
@@ -108,6 +111,8 @@ export declare class VscodeFrontendSession {
108
111
  */
109
112
  resolvedRequests: Map<number, FrontendRequest>;
110
113
  connection: 'disconnected' | 'connected' | 'closed';
114
+ /** Exact transport/recovery failure, retained until a fresh attachment succeeds. */
115
+ failure: Error | null;
111
116
  /**
112
117
  * `frontend.v2`'s descriptor and history carry NO timestamp, so the only
113
118
  * true times this frontend knows are its own: when this window attached, and
@@ -142,6 +147,9 @@ export declare class VscodeFrontendSession {
142
147
  /** Subscribe to every event this session projects, from now on. */
143
148
  onEvent(listener: (event: FrontendEvent) => void): () => void;
144
149
  onChange(observer: () => void): () => void;
150
+ /** Surface an actual stream/recovery error without inventing a native event or ending its turn. */
151
+ reportFailure(error: unknown): void;
152
+ onFailure(listener: (error: Error) => void): () => void;
145
153
  private projectEvent;
146
154
  /** What this attachment may do: permission AND the descriptor's own advertisement. */
147
155
  actions(): VscodeFrontendActions;
@@ -169,6 +177,7 @@ export declare class VscodeFrontendSession {
169
177
  throwIfCancelled?: () => void;
170
178
  onSubmitted?: () => void;
171
179
  }): Promise<FrontendEvent | null>;
180
+ private requireOpenAttachment;
172
181
  private require;
173
182
  private notify;
174
183
  }
package/dist/session.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mergeFrontendSnapshot } from '../vendor/supercode-frontend/client.mjs';
1
2
  /** How long a turn waits on a connection that dropped before saying it was lost (the follow loop's re-attach backs
2
3
  * off to 15 s; this outlasts a few of its attempts). */
3
4
  const DISCONNECT_GRACE_MS = 60_000;
@@ -46,6 +47,8 @@ export class VscodeFrontendSession {
46
47
  descriptor = null;
47
48
  history = [];
48
49
  historyCursor = 0;
50
+ historyEvents = [];
51
+ historyEventsDropped = 0;
49
52
  cursor = 0;
50
53
  /**
51
54
  * Every event this attachment has seen, in order — NOT just the last
@@ -69,6 +72,8 @@ export class VscodeFrontendSession {
69
72
  */
70
73
  resolvedRequests = new Map();
71
74
  connection = 'disconnected';
75
+ /** Exact transport/recovery failure, retained until a fresh attachment succeeds. */
76
+ failure = null;
72
77
  /**
73
78
  * `frontend.v2`'s descriptor and history carry NO timestamp, so the only
74
79
  * true times this frontend knows are its own: when this window attached, and
@@ -82,9 +87,12 @@ export class VscodeFrontendSession {
82
87
  #subscribed = false;
83
88
  #following = null;
84
89
  #heartbeat = null;
90
+ #takingControl = null;
91
+ #disposed = false;
85
92
  #resyncing = false;
86
93
  #listeners = new Set();
87
94
  #changeObservers = new Set();
95
+ #failureListeners = new Set();
88
96
  #eventListener = (event) => {
89
97
  this.applyEvent(event);
90
98
  };
@@ -106,17 +114,49 @@ export class VscodeFrontendSession {
106
114
  * BEFORE the snapshot RPC, so no event can fall between the two.
107
115
  */
108
116
  async attach(limit = 1000) {
109
- const snapshot = await this.client.attach(limit, { afterSequence: this.cursor });
117
+ if (this.#disposed)
118
+ throw new Error('frontend attachment is disposed');
119
+ const snapshot = await this.client.attach(limit, { afterSequence: this.cursor }).catch(error => {
120
+ this.reportFailure(error);
121
+ throw error;
122
+ });
123
+ if (this.#disposed) {
124
+ this.client.stopEvents();
125
+ throw new Error('frontend attachment is disposed');
126
+ }
127
+ const recovery = mergeFrontendSnapshot(snapshot, this.replay, Math.max(this.cursor, this.client.lastSequence ?? 0));
128
+ const authoritative = Array.isArray(snapshot.pending_requests);
110
129
  const staged = {
111
130
  descriptor: structuredClone(snapshot.descriptor),
112
- pendingRequests: new Map([...this.pendingRequests].map(([id, request]) => [id, structuredClone(request)])),
131
+ pendingRequests: new Map((authoritative ? snapshot.pending_requests : [...this.pendingRequests.values()])
132
+ .map(request => [request.id, structuredClone(request)])),
113
133
  backgroundTasks: structuredClone(this.backgroundTasks),
114
- cursor: Math.max(this.cursor, snapshot.history_cursor),
134
+ cursor: authoritative ? recovery.captureCursor : Math.max(this.cursor, snapshot.history_cursor),
115
135
  };
116
136
  // Validate and project the whole attachment before any observable field
117
137
  // moves or a live listener is registered.
118
- for (const event of snapshot.replay)
119
- this.projectEvent(staged, event);
138
+ if (authoritative) {
139
+ // Snapshot state is authoritative at capture, before any later events.
140
+ // Background listings have no separate snapshot field; retain their
141
+ // latest original event without replaying historical request state.
142
+ for (const event of recovery.replay) {
143
+ const payload = (event.payload ?? {});
144
+ if (event.kind === 'background_tasks' && Array.isArray(payload.tasks)) {
145
+ staged.backgroundTasks = payload.tasks
146
+ .filter(task => task && typeof task.id === 'string')
147
+ .map(task => ({ id: task.id, kind: String(task.kind ?? 'task'), description: String(task.description ?? '') }));
148
+ }
149
+ }
150
+ for (const event of recovery.later)
151
+ this.projectEvent(staged, event);
152
+ }
153
+ else {
154
+ // Legacy runtimes retain the previous replay-derived request behavior.
155
+ if (this.cursor > recovery.captureCursor)
156
+ staged.descriptor = structuredClone(this.descriptor);
157
+ for (const event of recovery.replay)
158
+ this.projectEvent(staged, event);
159
+ }
120
160
  // A request seen in the REPLAY is transcript, not an open ask. The runtime
121
161
  // re-sequences its request history at every turn boundary
122
162
  // (`compact_frontend_request_history`), and that compaction can keep a
@@ -132,12 +172,12 @@ export class VscodeFrontendSession {
132
172
  // reads idle while the agent loop is blocked on an approval it raised. Clearing that one left the agent waiting
133
173
  // forever with nothing to answer it (2026-10-07). Compaction happens only at a turn boundary, so a request raised
134
174
  // AFTER the replay's last boundary and not resolved since is still open, idle or not; only older ones are dropped.
135
- if (staged.descriptor?.turn_state !== 'busy' && staged.descriptor?.source_harness !== 'codex') {
175
+ if (!authoritative && staged.descriptor?.turn_state !== 'busy' && staged.descriptor?.source_harness !== 'codex') {
136
176
  // A turn's END, not `turn_completed`: that one is a model round-trip and fires mid-turn.
137
177
  const BOUNDARIES = new Set(['turn_succeeded', 'turn_failed', 'turn_interrupted']);
138
178
  let lastBoundary = -1;
139
179
  const raisedAt = new Map();
140
- for (const event of snapshot.replay) {
180
+ for (const event of recovery.replay) {
141
181
  if (BOUNDARIES.has(event.kind))
142
182
  lastBoundary = Math.max(lastBoundary, event.sequence);
143
183
  const payload = (event.payload ?? {});
@@ -156,22 +196,41 @@ export class VscodeFrontendSession {
156
196
  this.descriptor = staged.descriptor;
157
197
  this.history = structuredClone(snapshot.history);
158
198
  this.historyCursor = snapshot.history_cursor;
159
- this.appendTranscript(structuredClone(snapshot.replay));
199
+ this.historyEvents = structuredClone(snapshot.history_events ?? []);
200
+ this.historyEventsDropped = snapshot.history_events_dropped ?? 0;
201
+ this.appendTranscript(structuredClone(recovery.replay));
160
202
  this.pendingRequests = staged.pendingRequests;
161
203
  this.backgroundTasks = staged.backgroundTasks;
204
+ const previousCursor = this.cursor;
162
205
  this.cursor = staged.cursor;
163
206
  this.connection = 'connected';
207
+ this.failure = null;
164
208
  this.attachedAtMs ??= Date.now();
165
209
  if (!this.#subscribed) {
166
210
  this.client.on('event', this.#eventListener);
167
211
  this.client.on('disconnect', this.#disconnectListener);
168
212
  this.#subscribed = true;
169
213
  }
214
+ // Recovery delivers original unseen events at their original sequences.
215
+ // Historical display data never goes through the live-state projector;
216
+ // the snapshot's request map and descriptor remain authoritative.
217
+ for (const event of [...this.historyEvents, ...recovery.replay]) {
218
+ if (event.sequence <= previousCursor)
219
+ continue;
220
+ for (const listener of [...this.#listeners]) {
221
+ try {
222
+ listener(event);
223
+ }
224
+ catch { /* A view cannot stop recovery. */ }
225
+ }
226
+ }
170
227
  this.notify();
171
228
  return snapshot;
172
229
  }
173
230
  /** Start consuming the already-open live stream. Resolves when it ends. */
174
231
  follow() {
232
+ if (this.#disposed)
233
+ return Promise.resolve();
175
234
  if (!this.#following) {
176
235
  this.#following = this.client.startEvents({ afterSequence: this.cursor }).finally(() => {
177
236
  this.#following = null;
@@ -184,10 +243,23 @@ export class VscodeFrontendSession {
184
243
  * never call this; only a permission that can submit needs the lease.
185
244
  */
186
245
  async takeControlAndHold() {
246
+ this.requireOpenAttachment();
187
247
  if (!this.permissions.has('interact'))
188
248
  return;
189
- const lease = await this.client.takeControl();
190
- this.startHeartbeat(lease.lease_ttl_ms);
249
+ if (this.#takingControl)
250
+ return this.#takingControl;
251
+ const taking = this.client.takeControl().then((lease) => {
252
+ this.requireOpenAttachment();
253
+ this.startHeartbeat(lease.lease_ttl_ms);
254
+ });
255
+ this.#takingControl = taking;
256
+ try {
257
+ await taking;
258
+ }
259
+ finally {
260
+ if (this.#takingControl === taking)
261
+ this.#takingControl = null;
262
+ }
191
263
  }
192
264
  startHeartbeat(ttlMs) {
193
265
  this.stopHeartbeat();
@@ -205,6 +277,10 @@ export class VscodeFrontendSession {
205
277
  this.#heartbeat = null;
206
278
  }
207
279
  dispose() {
280
+ if (this.#disposed)
281
+ return;
282
+ this.#disposed = true;
283
+ this.connection = 'closed';
208
284
  this.stopHeartbeat();
209
285
  this.client.stopEvents();
210
286
  if (this.#subscribed) {
@@ -212,8 +288,10 @@ export class VscodeFrontendSession {
212
288
  this.client.off('disconnect', this.#disconnectListener);
213
289
  this.#subscribed = false;
214
290
  }
291
+ this.notify();
215
292
  this.#listeners.clear();
216
293
  this.#changeObservers.clear();
294
+ this.#failureListeners.clear();
217
295
  }
218
296
  /** Keep the transcript in order and free of the duplicates a re-attach brings. */
219
297
  appendTranscript(events) {
@@ -225,8 +303,8 @@ export class VscodeFrontendSession {
225
303
  this.replay.push(event);
226
304
  }
227
305
  this.replay.sort((left, right) => left.sequence - right.sequence);
228
- if (this.replay.length > 2000)
229
- this.replay = this.replay.slice(-2000);
306
+ if (this.replay.length > 4096)
307
+ this.replay = this.replay.slice(-4096);
230
308
  }
231
309
  applyEvent(event) {
232
310
  let applied;
@@ -264,9 +342,7 @@ export class VscodeFrontendSession {
264
342
  return;
265
343
  this.#resyncing = true;
266
344
  void this.attach()
267
- .catch(() => {
268
- // An unreachable runtime is reported by the next action, not here.
269
- })
345
+ .catch(error => this.reportFailure(error))
270
346
  .finally(() => {
271
347
  this.#resyncing = false;
272
348
  });
@@ -280,6 +356,29 @@ export class VscodeFrontendSession {
280
356
  this.#changeObservers.add(observer);
281
357
  return () => this.#changeObservers.delete(observer);
282
358
  }
359
+ /** Surface an actual stream/recovery error without inventing a native event or ending its turn. */
360
+ reportFailure(error) {
361
+ if (this.connection === 'closed')
362
+ return;
363
+ const failure = error instanceof Error ? error : new Error(String(error));
364
+ if (this.failure?.message === failure.message && this.connection === 'disconnected')
365
+ return;
366
+ this.failure = failure;
367
+ this.connection = 'disconnected';
368
+ for (const listener of [...this.#failureListeners]) {
369
+ try {
370
+ listener(failure);
371
+ }
372
+ catch { /* A view cannot stop recovery. */ }
373
+ }
374
+ this.notify();
375
+ }
376
+ onFailure(listener) {
377
+ this.#failureListeners.add(listener);
378
+ if (this.failure)
379
+ listener(this.failure);
380
+ return () => this.#failureListeners.delete(listener);
381
+ }
283
382
  projectEvent(state, event) {
284
383
  if (event.sequence <= state.cursor)
285
384
  return false;
@@ -344,6 +443,10 @@ export class VscodeFrontendSession {
344
443
  }
345
444
  /** Run one controller action, re-taking an expired lease exactly once. */
346
445
  async withControl(action) {
446
+ // An early picker registration can expose actions while the initial lease
447
+ // is arriving. Wait for that attempt, then let the runtime enforce its lease.
448
+ await this.#takingControl?.catch(() => undefined);
449
+ this.requireOpenAttachment();
347
450
  try {
348
451
  return await action();
349
452
  }
@@ -351,8 +454,8 @@ export class VscodeFrontendSession {
351
454
  const name = error?.name ?? '';
352
455
  if (!LEASE_ERROR_NAMES.includes(name) || !this.permissions.has('interact'))
353
456
  throw error;
354
- const lease = await this.client.takeControl();
355
- this.startHeartbeat(lease.lease_ttl_ms);
457
+ await this.takeControlAndHold();
458
+ this.requireOpenAttachment();
356
459
  return action();
357
460
  }
358
461
  }
@@ -402,6 +505,8 @@ export class VscodeFrontendSession {
402
505
  }
403
506
  }
404
507
  async detach() {
508
+ // Do not let a pending acquisition succeed after this client detached.
509
+ await this.#takingControl?.catch(() => undefined);
405
510
  this.require('detach');
406
511
  await this.client.detach();
407
512
  this.connection = 'disconnected';
@@ -415,8 +520,7 @@ export class VscodeFrontendSession {
415
520
  }
416
521
  async takeover() {
417
522
  this.require('takeover');
418
- const lease = await this.client.takeControl();
419
- this.startHeartbeat(lease.lease_ttl_ms);
523
+ await this.takeControlAndHold();
420
524
  this.notify();
421
525
  }
422
526
  async heartbeat() {
@@ -447,14 +551,15 @@ export class VscodeFrontendSession {
447
551
  settle?.(event);
448
552
  }
449
553
  });
554
+ const unfail = this.onFailure(error => fail?.(error));
450
555
  // A turn that never sees its terminal event must still end: the editor
451
556
  // holds the chat response open until this handler resolves, and a frontend
452
557
  // that hangs one is worse than one that ends it a beat early. The
453
558
  // descriptor going idle after the submit is the second, independent signal.
454
559
  let sawBusy = false;
455
- // A stream that ends is re-attached from its cursor (the extension's follow loop), and the replay ends the turn;
456
- // only a connection closed, or one still down after DISCONNECT_GRACE_MS, ends the wait, said: a response held open
457
- // with nothing in it is the silent failure.
560
+ // Explicit transport/recovery failures end this view through onFailure;
561
+ // the native turn can still be running. A disconnect with no explicit
562
+ // failure gets a grace period while the follow loop reattaches.
458
563
  let down;
459
564
  const lost = () => fail?.(new Error('The connection to this conversation\'s runtime was lost before its turn ended; the turn may still be running. Reopen the chat to see where it stands.'));
460
565
  finished.catch(() => undefined); // a loss while the submit is still in flight is answered by the submit's own error
@@ -490,9 +595,16 @@ export class VscodeFrontendSession {
490
595
  clearTimeout(down);
491
596
  unsubscribe();
492
597
  unwatch();
598
+ unfail();
493
599
  }
494
600
  }
601
+ requireOpenAttachment() {
602
+ if (this.#disposed || this.connection === 'closed')
603
+ throw new Error('frontend attachment is closed');
604
+ }
495
605
  require(action) {
606
+ if (this.failure && action === 'submit')
607
+ throw this.failure;
496
608
  if (!this.actions()[action])
497
609
  throw new Error(`frontend action ${action} is unavailable`);
498
610
  }
@@ -93,21 +93,29 @@ export class ChatSetupFlow {
93
93
  this.processes.set(operation.id, process);
94
94
  let ended = false;
95
95
  let exitCode;
96
+ let exitRefreshesRemaining = 2;
96
97
  void process.exited.then(code => { ended = true; exitCode = code; });
97
98
  // Readiness comes only from the host's native inventory. A successful
98
99
  // process exit, a browser opening, and a protocol handshake are not login.
99
100
  while (current()) {
101
+ const refreshingAfterExit = ended;
100
102
  await this.refresh(operation);
101
103
  if (setupAgents(this.host.state()).some(a => a.harness === operation.harness && a.signedIn))
102
104
  break;
103
- if (ended)
105
+ // The inventory can settle after the native login process exits. Give
106
+ // it two reads begun after that exit, keeping inventory authoritative
107
+ // even for an exit code of zero and retiring cancelled operations.
108
+ if (refreshingAfterExit && --exitRefreshesRemaining === 0)
104
109
  throw new Error(`Sign-in ended ${exitCode === undefined ? 'without an exit status' : `with exit code ${exitCode}`}, without a signed-in account.`);
105
110
  await new Promise(resolve => setTimeout(resolve, 1000));
106
111
  }
107
112
  }
108
113
  if (!current())
109
114
  return;
110
- this.notice = operation.harness === 'codex' ? 'ChatGPT' : operation.harness === 'claude-code' ? 'Claude' : undefined;
115
+ // The account the host names for the agent now signed in (one signed in from Cyclotron's Accounts menu, say), else
116
+ // the provider its native sign-in belongs to.
117
+ const signedIn = setupAgents(this.host.state()).find(a => a.harness === operation.harness && a.signedIn);
118
+ this.notice = signedIn?.account || (operation.harness === 'codex' ? 'ChatGPT' : operation.harness === 'claude-code' ? 'Claude' : undefined);
111
119
  this.update(operation, 'signed-in');
112
120
  // Retire only the terminal this click launched. Its native inventory has
113
121
  // already confirmed login; closing it never supplies that confirmation.
package/dist/view.d.ts CHANGED
@@ -133,6 +133,8 @@ export interface ProjectionContext {
133
133
  connectionId: string;
134
134
  /** Render the runtime's echo of a prompt (history replay does; live does not). */
135
135
  echoOwnPrompts?: boolean;
136
+ /** Show input arriving in an observed turn, without inventing a native request turn. */
137
+ liveUserMessages?: boolean;
136
138
  /** The request a `request_resolved` answers, so its sentence can name it. */
137
139
  pendingRequest?: (requestId: number) => FrontendRequest | undefined;
138
140
  /**
@@ -172,8 +174,8 @@ export interface ProjectedTurn {
172
174
  }
173
175
  /**
174
176
  * A "user" message no person typed: Claude Code's `<task-notification>` (a background task reporting back) or the
175
- * editor's automatic tripwire nudge. Neither is shown as the person's words. A task notification is said as one
176
- * short line ("Background task completed: <its summary>"); a nudge is not shown. Undefined for a person's message.
177
+ * editor's automatic tripwire nudge or Claude's unmarked interruption record. None is shown as the person's words.
178
+ * A task notification and interruption are short notices; a nudge is not shown. Undefined for a person's message.
177
179
  */
178
180
  export declare function automaticPrompt(body: string): {
179
181
  notice?: string;
package/dist/view.js CHANGED
@@ -440,12 +440,26 @@ export function projectEvent(event, context) {
440
440
  const payload = payloadOf(event);
441
441
  switch (event.kind) {
442
442
  // crates/harness/src/server.rs `submit_claimed` — the runtime's echo of the
443
- // prompt. VS Code already renders the request turn, so the live view drops
444
- // its own echo; only the session-history projection reads it.
445
- case 'user_message':
443
+ // initial prompt, before `turn_started`. VS Code renders that request turn.
444
+ // A rejoined turn has no submitting handler; later runtime input is visible
445
+ // within its live response and remains a user turn in canonical history.
446
+ case 'user_message': {
447
+ if (context.liveUserMessages) {
448
+ const body = text(payload.text);
449
+ const automatic = automaticPrompt(body);
450
+ if (automatic)
451
+ return automatic.notice
452
+ ? [stable({ part: 'markdown', value: `\n\n${automatic.notice}\n\n` })]
453
+ : [];
454
+ const prefix = '\n\n**Additional input**\n\n';
455
+ const suffix = '\n\n';
456
+ const value = clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters - prefix.length - suffix.length);
457
+ return value ? [stable({ part: 'markdown', value: `${prefix}${value}${suffix}` })] : [];
458
+ }
446
459
  return context.echoOwnPrompts
447
460
  ? [stable({ part: 'markdown', value: clamp(stripControls(text(payload.text)), VSCODE_CHAT_LIMITS.partCharacters) })]
448
461
  : [];
462
+ }
449
463
  // crates/harness/src/server.rs `submit_claimed`, and
450
464
  // crates/runtime/src/event.rs `AgentEvent::TurnCompleted` (one model
451
465
  // round-trip). Lifecycle only — the turn resolves on these, never renders.
@@ -608,11 +622,11 @@ export function projectEvent(event, context) {
608
622
  return [stable({ part: 'warning', value: 'The turn was interrupted.' })];
609
623
  // crates/harness/src/server.rs `submit_claimed` emits `turn_failed`. The
610
624
  // other four are the error kinds the donor-neutral reader in
611
- // crates/frontend-model/src/transcript.rs treats identically:
625
+ // sdk/interchange/frontend-transcript.mjs treats identically:
612
626
  // `transport_error` from crates/opencode-frontend/src/server.rs,
613
627
  // `watch_error` from crates/interchange/src/watch.rs, and
614
628
  // `runtime_disconnected`, which a frontend synthesizes for itself
615
- // (crates/frontend-tui/src/app.rs). `stream_error` has no producer in this
629
+ // (sdk/interchange/frontend-app.mjs). `stream_error` has no producer in this
616
630
  // repository and is read only because that reader reads it.
617
631
  case 'turn_failed':
618
632
  case 'stream_error':
@@ -629,7 +643,7 @@ export function projectEvent(event, context) {
629
643
  }
630
644
  // Declared by the runtime descriptor's `display.event_kinds`
631
645
  // (crates/harness/src/server.rs `frontend_descriptor`); read here the way
632
- // crates/frontend-model/src/transcript.rs reads them.
646
+ // sdk/interchange/frontend-transcript.mjs reads them.
633
647
  case 'scheduled_prompt_started':
634
648
  case 'scheduled_prompt_deferred':
635
649
  case 'scheduled_prompt_completed':
@@ -654,7 +668,7 @@ export function projectEvent(event, context) {
654
668
  case 'warning':
655
669
  return [stable({ part: 'warning', value: clamp(stripControls(text(payload.message)), VSCODE_CHAT_LIMITS.partCharacters) })];
656
670
  // Not in this runtime's declared set; read the way
657
- // crates/frontend-model/src/transcript.rs reads a donor that emits them.
671
+ // sdk/interchange/frontend-transcript.mjs reads a donor that emits them.
658
672
  case 'reasoning':
659
673
  case 'thinking': {
660
674
  const body = clamp(stripControls(text(payload.text ?? payload.summary, opaqueText(event.payload))), VSCODE_CHAT_LIMITS.partCharacters);
@@ -704,8 +718,10 @@ function messageText(message) {
704
718
  if (!entry || typeof entry !== 'object' || Array.isArray(entry))
705
719
  return '';
706
720
  const part = entry;
721
+ // Opaque transcript parts can carry structured text. Use the same bounded
722
+ // JSON rendering as live payloads rather than JavaScript's object coercion.
707
723
  if (['text', 'input_text', 'output_text'].includes(String(part.type)))
708
- return String(part.text ?? '');
724
+ return text(part.text);
709
725
  if (['image', 'image_url', 'input_image'].includes(String(part.type)))
710
726
  return '[image]';
711
727
  return '';
@@ -715,8 +731,8 @@ function messageText(message) {
715
731
  }
716
732
  /**
717
733
  * A "user" message no person typed: Claude Code's `<task-notification>` (a background task reporting back) or the
718
- * editor's automatic tripwire nudge. Neither is shown as the person's words. A task notification is said as one
719
- * short line ("Background task completed: <its summary>"); a nudge is not shown. Undefined for a person's message.
734
+ * editor's automatic tripwire nudge or Claude's unmarked interruption record. None is shown as the person's words.
735
+ * A task notification and interruption are short notices; a nudge is not shown. Undefined for a person's message.
720
736
  */
721
737
  export function automaticPrompt(body) {
722
738
  const trimmed = body.trimStart();
@@ -728,6 +744,9 @@ export function automaticPrompt(body) {
728
744
  }
729
745
  if (trimmed.startsWith('[Volter editor tripwire'))
730
746
  return {};
747
+ // Claude persists this reserved notice as an unmarked user-role record.
748
+ if (trimmed.trimEnd() === '[Request interrupted by user]')
749
+ return { notice: '_Turn interrupted._' };
731
750
  return undefined;
732
751
  }
733
752
  export function projectReplayTurns(events, granted, context, history = []) {
@@ -857,6 +876,11 @@ export function projectHistory(history, granted, pendingToolNames = new Set()) {
857
876
  pushAssistant([{ part: 'markdown', value: `\n\n${automatic.notice}\n\n` }]);
858
877
  continue;
859
878
  }
879
+ // The codec preserves the harness's provenance. Image-coordinate notes,
880
+ // compact summaries and injected context are not the person's words.
881
+ if (message.metadata?.isMeta === 'true' || message.metadata?.isCompactSummary === 'true' ||
882
+ message.metadata?.promptSource === 'system')
883
+ continue;
860
884
  toolRows.clear();
861
885
  turns.push({ role: 'user', prompt: clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters) });
862
886
  continue;
@@ -0,0 +1,13 @@
1
+ import * as vscode from 'vscode';
2
+ import type { WorkbenchToolsEndpoint } from './host-controls.js';
3
+ export declare class WorkbenchTools implements vscode.Disposable {
4
+ #private;
5
+ /** Serve on the endpoint the host names, or nothing when it names none. A changed endpoint replaces the server.
6
+ * Calls apply in order, so two quick state replies cannot race a close against a listen. */
7
+ serve(endpoint: WorkbenchToolsEndpoint | undefined): Promise<void>;
8
+ private apply;
9
+ private answer;
10
+ private handle;
11
+ private close;
12
+ dispose(): void;
13
+ }