@volter/supercode-frontend-vscode 0.1.29 → 0.1.30

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/dist/extension.js CHANGED
@@ -323,6 +323,7 @@ export async function activate(context) {
323
323
  let applying = Promise.resolve();
324
324
  let pendingDraft;
325
325
  let focusedResource;
326
+ let focusGeneration = 0;
326
327
  const activeDraft = () => pendingDraft?.resource && pendingDraft.resource.toString() === focusedResource?.toString() ? pendingDraft : undefined;
327
328
  const draftSelection = () => {
328
329
  const choice = activeDraft()?.choice;
@@ -333,8 +334,10 @@ export async function activate(context) {
333
334
  focusedResource = resource;
334
335
  if (pendingDraft?.resource && pendingDraft.resource.toString() !== resource?.toString())
335
336
  pendingDraft = undefined;
336
- if (changed)
337
+ if (changed) {
338
+ focusGeneration++;
337
339
  controlsChanged.fire();
340
+ }
338
341
  };
339
342
  const frontends = new SupercodeFrontends();
340
343
  // Runtimes attached by address ("Set Runtime Token", "Attach to Runtime")
@@ -406,11 +409,20 @@ export async function activate(context) {
406
409
  const id = resource ? boundId(resource) : controls?.state.activeSession;
407
410
  return { permissions: id ? permissions.get(id) ?? 'ask' : permission };
408
411
  };
409
- const connectManaged = async () => {
410
- const env = controls.state.connection;
411
- if (!env || !controls.state.activeSession)
412
+ const savedConnections = new Map();
413
+ const saveManagedHistory = async (connection) => {
414
+ const state = await controls.open(connection.id);
415
+ if (state.activeSession !== connection.id)
416
+ throw new Error('The conversation changed before its history could be saved.');
417
+ connection.persistedHistory = state.history;
418
+ connection.historyTruncated = state.historyTruncated;
419
+ savedConnections.set(connection.id, connection);
420
+ };
421
+ const connectManaged = async (state = controls.state) => {
422
+ const env = state.connection;
423
+ if (!env || !state.activeSession)
412
424
  throw new Error('The requested conversation has no runtime connection.');
413
- const id = controls.state.activeSession;
425
+ const id = state.activeSession;
414
426
  const existing = frontends.get(id);
415
427
  if (existing && existing.baseUrl === env.SUPERCODE_FRONTEND_URL && existing.session.connection !== 'closed') {
416
428
  if (existing.session.connection !== 'connected')
@@ -421,7 +433,7 @@ export async function activate(context) {
421
433
  await frontends.disconnect(connection.id);
422
434
  const connection = await frontends.connect({ connectionId: id, baseUrl: env.SUPERCODE_FRONTEND_URL,
423
435
  token: await readCredentialFile(env.SUPERCODE_FRONTEND_CREDENTIAL_FILE), clientId: env.SUPERCODE_FRONTEND_CLIENT_ID,
424
- permissions: normalizePermissions(env.SUPERCODE_FRONTEND_PERMISSIONS, ['observe']), label: runtimeIdentity(controls.state.selection.harness, controls.state.selection.model || controls.state.actualModel).name }, log);
436
+ permissions: normalizePermissions(env.SUPERCODE_FRONTEND_PERMISSIONS, ['observe']), label: runtimeIdentity(state.selection.harness, state.selection.model || state.actualModel).name }, log);
425
437
  if (!connection)
426
438
  throw new Error('Could not connect to the requested conversation.');
427
439
  return connection;
@@ -458,17 +470,20 @@ export async function activate(context) {
458
470
  };
459
471
  let refreshingSetup;
460
472
  const refreshSetup = () => {
461
- refreshingSetup ??= (async () => {
473
+ if (refreshingSetup)
474
+ return refreshingSetup;
475
+ const pending = applying.then(async () => {
462
476
  if (!controls)
463
477
  return;
464
478
  // Discover and publish readiness even while a blank chat holds a draft.
465
479
  // Only the automatic reveal is suppressed; discovery must never stall.
466
480
  await controls.refresh();
481
+ const state = controls.state;
467
482
  controlsChanged.fire();
468
483
  const preserveDraft = focusedResource?.path.startsWith('/untitled-');
469
- if (controls.state.setup?.ready) {
484
+ if (state.setup?.ready) {
470
485
  setupRevealed = false;
471
- await connectManaged();
486
+ await connectManaged(state);
472
487
  controlsChanged.fire();
473
488
  if (!preserveDraft && !setupHandoffComplete())
474
489
  await revealManaged();
@@ -479,7 +494,9 @@ export async function activate(context) {
479
494
  if (!preserveDraft)
480
495
  await revealSetup();
481
496
  }
482
- })().finally(() => { refreshingSetup = undefined; });
497
+ });
498
+ applying = pending.then(() => undefined, () => undefined);
499
+ refreshingSetup = pending.finally(() => { refreshingSetup = undefined; });
483
500
  return refreshingSetup;
484
501
  };
485
502
  const setupTerminals = new Map();
@@ -512,8 +529,8 @@ export async function activate(context) {
512
529
  setupTerminals.set(key, terminal);
513
530
  terminal.show();
514
531
  }));
515
- const connectionFor = async (resource) => {
516
- focusSession(resource);
532
+ const restoring = new Map();
533
+ const connectionFor = async (resource, activate = true) => {
517
534
  if (!controls) {
518
535
  const connection = frontends.get(resource.authority);
519
536
  if (!connection)
@@ -521,7 +538,7 @@ export async function activate(context) {
521
538
  return connection;
522
539
  }
523
540
  const attached = attachedIds.has(resource.authority) ? frontends.get(resource.authority) : undefined;
524
- if (focusedAttached !== attached) {
541
+ if (activate && focusedAttached !== attached) {
525
542
  focusedAttached = attached;
526
543
  controlsChanged.fire();
527
544
  }
@@ -530,26 +547,55 @@ export async function activate(context) {
530
547
  const id = boundId(resource);
531
548
  if (!id)
532
549
  throw new Error('This chat has no backend session binding. Start a new chat; no message was sent.');
533
- const active = managed();
534
- if (active && active.id !== id && (active.session.descriptor?.turn_state === 'busy' || active.session.pendingRequests.size))
535
- throw new Error('Finish or cancel the active turn before opening another conversation.');
550
+ // Content refresh is not a focus change. Replaying a previously connected
551
+ // conversation must not retire the runtime the person just selected.
552
+ if (!activate) {
553
+ const saved = frontends.get(id) ?? savedConnections.get(id);
554
+ if (saved)
555
+ return saved;
556
+ }
557
+ const existing = restoring.get(id);
558
+ // Coalesce adjacent requests only. A later A after a queued B must still
559
+ // activate A last, rather than borrowing an earlier A's completion.
560
+ if (existing?.completion === applying)
561
+ return existing.pending;
562
+ const pending = applying.then(async () => {
563
+ const active = managed();
564
+ if (active && active.id !== id && (active.session.descriptor?.turn_state === 'busy' || active.session.pendingRequests.size))
565
+ throw new Error('Finish or cancel the active turn before opening another conversation.');
566
+ if (active && active.id !== id) {
567
+ // Capture the exact saved transcript while its runtime still belongs
568
+ // to the host. Subsequent background content reads need no activation.
569
+ await saveManagedHistory(active);
570
+ }
571
+ let state;
572
+ try {
573
+ state = await controls.open(id);
574
+ }
575
+ catch (error) {
576
+ log.error(`Could not restore conversation ${id}: ${String(error)}`);
577
+ throw error;
578
+ }
579
+ if (state.activeSession !== id)
580
+ throw new Error('The host did not restore the requested conversation.');
581
+ const connection = await connectManaged(state);
582
+ connection.persistedHistory = state.history;
583
+ connection.historyTruncated = state.historyTruncated;
584
+ savedConnections.set(id, connection);
585
+ if (active !== connection)
586
+ controlsChanged.fire();
587
+ return connection;
588
+ });
589
+ const completion = pending.then(() => undefined, () => undefined);
590
+ restoring.set(id, { pending, completion });
591
+ applying = completion;
536
592
  try {
537
- await controls.open(id);
593
+ return await pending;
538
594
  }
539
- catch (error) {
540
- log.error(`Could not restore conversation ${id}: ${String(error)}`);
541
- throw error;
595
+ finally {
596
+ if (restoring.get(id)?.pending === pending)
597
+ restoring.delete(id);
542
598
  }
543
- const history = controls.state.history;
544
- const historyTruncated = controls.state.historyTruncated;
545
- const connection = await connectManaged();
546
- connection.persistedHistory = history;
547
- connection.historyTruncated = historyTruncated;
548
- // Resolving an already-focused session must not republish its catalogue
549
- // while the native widget is still opening that same resource.
550
- if (active !== connection)
551
- controlsChanged.fire();
552
- return connection;
553
599
  };
554
600
  async function applySelection(selection) {
555
601
  if (!controls)
@@ -557,9 +603,12 @@ export async function activate(context) {
557
603
  const active = managed();
558
604
  if (active?.session.descriptor?.turn_state === 'busy' || active?.session.pendingRequests.size)
559
605
  throw new Error('Finish or cancel the current turn before starting a new chat.');
560
- await controls.select(selection);
561
- await connectManaged();
606
+ if (active)
607
+ await saveManagedHistory(active);
608
+ const state = await controls.select(selection);
609
+ const connection = await connectManaged(state);
562
610
  controlsChanged.fire();
611
+ return connection;
563
612
  }
564
613
  const openNewChat = async () => {
565
614
  if (!controls || !granted.has('chatSessionsProvider'))
@@ -578,6 +627,7 @@ export async function activate(context) {
578
627
  const draft = { resource: vscode.Uri.from({ scheme: SESSION_SCHEME, path: `/untitled-${randomUUID()}` }) };
579
628
  pendingDraft = draft;
580
629
  focusedResource = draft.resource;
630
+ focusGeneration++;
581
631
  focusedAttached = undefined;
582
632
  controlsChanged.fire();
583
633
  await vscode.commands.executeCommand(controls.state.openSessionCommand, draft.resource.toString());
@@ -672,9 +722,8 @@ export async function activate(context) {
672
722
  const selection = choice?.kind === 'agent' ? choice.selection
673
723
  : { harness: loginHarness ?? controls.state.selection.harness, model: '', effort: '' };
674
724
  const pending = applying.then(() => applySelection(selection));
675
- applying = pending.catch(() => undefined);
676
- await pending;
677
- const connection = frontends.get(controls.state.activeSession);
725
+ applying = pending.then(() => undefined, () => undefined);
726
+ const connection = await pending;
678
727
  savePermission(connection.id, turnPermission(inputState.groups.find(group => group.id === 'permissions')?.selected?.id));
679
728
  if (pendingDraft === draft) {
680
729
  pendingDraft = undefined;
@@ -682,8 +731,7 @@ export async function activate(context) {
682
731
  }
683
732
  return connection;
684
733
  },
685
- resolve: connectionFor,
686
- focus: focusSession,
734
+ resolve: (resource) => connectionFor(resource, false),
687
735
  attached: (connection) => attachedIds.has(connection.id),
688
736
  catalogue: () => controls?.state.sessions ?? [],
689
737
  bindings: boundId,
@@ -695,7 +743,12 @@ export async function activate(context) {
695
743
  focusSession(resource ? vscode.Uri.parse(resource) : undefined);
696
744
  }));
697
745
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.openSession', async (id) => {
698
- await connectionFor(vscode.Uri.from({ scheme: SESSION_SCHEME, authority: id, path: '/conversation' }));
746
+ const resource = vscode.Uri.from({ scheme: SESSION_SCHEME, authority: id, path: '/conversation' });
747
+ focusSession(resource);
748
+ const generation = focusGeneration;
749
+ await connectionFor(resource);
750
+ if (generation !== focusGeneration)
751
+ return;
699
752
  await revealManaged();
700
753
  }));
701
754
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.configure', async (supplied) => {
@@ -945,7 +998,28 @@ export async function activate(context) {
945
998
  p.iconPath = icon();
946
999
  };
947
1000
  context.subscriptions.push({ dispose: () => sessionParticipant.dispose() }, controlsChanged.event(updateIdentity), frontends.onDidChange(updateIdentity));
948
- registerSessions(context, frontends, sessionParticipant, granted, handler, controls ? providerControls : undefined);
1001
+ // Replayed turns outlive the active composer participant. Keep their
1002
+ // author tied to the saved harness, including passive background loads.
1003
+ const historyParticipants = new Map();
1004
+ const historyParticipant = (connection) => {
1005
+ if (!granted.has('chatParticipantPrivate'))
1006
+ return sessionParticipant.id;
1007
+ const harness = connection.session.descriptor?.source_harness
1008
+ ?? controls?.state.sessions.find(entry => entry.id === connection.id)?.selection.harness;
1009
+ if (!harness)
1010
+ return sessionParticipant.id;
1011
+ let author = historyParticipants.get(harness);
1012
+ if (!author) {
1013
+ const name = harnessDisplayName(harness);
1014
+ author = vscode.chat.createDynamicChatParticipant(`${SESSION_SCHEME}.history.${encodeURIComponent(harness)}`, { name: name.replace(/[^a-zA-Z0-9]/g, ''), fullName: name, publisherName: 'Volter' }, handler);
1015
+ const provider = runtimeIdentity(harness, '').provider;
1016
+ author.iconPath = provider ? vscode.Uri.joinPath(context.extensionUri, 'media', `${provider}.svg`) : new vscode.ThemeIcon('hubot');
1017
+ historyParticipants.set(harness, author);
1018
+ context.subscriptions.push(author);
1019
+ }
1020
+ return author.id;
1021
+ };
1022
+ registerSessions(context, frontends, sessionParticipant, granted, handler, historyParticipant, controls ? providerControls : undefined);
949
1023
  }
950
1024
  catch (error) {
951
1025
  log.warn(`Volter Harness chat sessions were not registered: ${String(error?.message ?? error)}`);
@@ -1315,7 +1389,7 @@ function applyTiming(item, connection) {
1315
1389
  lastRequestEnded: connection.session.lastTurnEndedMs ?? undefined,
1316
1390
  };
1317
1391
  }
1318
- function registerSessions(context, frontends, participant, granted, handler, controls) {
1392
+ function registerSessions(context, frontends, participant, granted, handler, historyParticipant, controls) {
1319
1393
  const turns = vscode;
1320
1394
  const items = () => {
1321
1395
  return (controls?.catalogue() ?? []).filter(entry => !frontends.get(entry.id)).map(entry => {
@@ -1369,12 +1443,15 @@ function registerSessions(context, frontends, participant, granted, handler, con
1369
1443
  }));
1370
1444
  }
1371
1445
  context.subscriptions.push(vscode.chat.registerChatSessionContentProvider(SESSION_SCHEME, {
1372
- provideChatSessionContent: async (resource, _token, { inputState }) => {
1373
- controls?.focus(resource);
1446
+ provideChatSessionContent: async (resource, token, { inputState }) => {
1447
+ if (token.isCancellationRequested)
1448
+ throw new vscode.CancellationError();
1374
1449
  const boundHandler = (request, chatContext, stream, token) => handler(request, { ...chatContext, chatSessionContext: { chatSessionItem: { resource, label: 'Conversation' } } }, stream, token);
1375
1450
  // Only a native untitled resource can create a conversation. Existing
1376
1451
  // resources must resolve their exact saved binding; there is no fallback.
1377
1452
  const connection = controls ? (resource.path.startsWith('/untitled-') && !controls.bindings(resource) ? undefined : await controls.resolve(resource)) : frontends.get(resource.authority);
1453
+ if (token.isCancellationRequested)
1454
+ throw new vscode.CancellationError();
1378
1455
  if (!connection) {
1379
1456
  return { history: [], requestHandler: boundHandler };
1380
1457
  }
@@ -1387,13 +1464,14 @@ function registerSessions(context, frontends, participant, granted, handler, con
1387
1464
  // `state.history`), so the copy taken when this window attached is
1388
1465
  // stale as soon as a turn runs. The generated client reuses the
1389
1466
  // already-open event stream, so this is a snapshot, not a reconnect.
1390
- const attached = await connection.session.attach().then(() => true).catch(() => {
1467
+ const attached = frontends.get(connection.id) === connection && await connection.session.attach().then(() => true).catch(() => {
1391
1468
  // An unreachable runtime replays what this window last saw.
1392
1469
  return false;
1393
1470
  });
1394
1471
  // The attach snapshot IS the session's history: the runtime's own
1395
1472
  // canonical transcript, replayed as turns.
1396
1473
  const history = [];
1474
+ const authorId = historyParticipant(connection);
1397
1475
  // A hosted harness runtime has no canonical ChatMessage history —
1398
1476
  // `HostedHarnessRuntime::attach` returns an empty one and puts the
1399
1477
  // conversation in `replay`. Read whichever the runtime actually
@@ -1419,13 +1497,13 @@ function registerSessions(context, frontends, participant, granted, handler, con
1419
1497
  canRespond: connection.session.descriptor?.actions.respond !== false,
1420
1498
  });
1421
1499
  if (connection.historyTruncated)
1422
- history.push(new turns.ChatRequestTurn('[Earlier conversation history]', undefined, [], participant.id, []), new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart('_Showing the latest 120 saved messages. Earlier context remains in the harness session._')], {}, participant.id));
1500
+ history.push(new turns.ChatRequestTurn('[Earlier conversation history]', undefined, [], authorId, []), new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart('_Showing the latest 120 saved messages. Earlier context remains in the harness session._')], {}, authorId));
1423
1501
  for (const turn of projected) {
1424
1502
  if (turn.role === 'user') {
1425
- history.push(new turns.ChatRequestTurn(turn.prompt ?? '', undefined, [], participant.id, []));
1503
+ history.push(new turns.ChatRequestTurn(turn.prompt ?? '', undefined, [], authorId, []));
1426
1504
  continue;
1427
1505
  }
1428
- history.push(new turns.ChatResponseTurn(historyParts(oneRowPerToolCall(turn.parts ?? [])), {}, participant.id));
1506
+ history.push(new turns.ChatResponseTurn(historyParts(oneRowPerToolCall(turn.parts ?? [])), {}, authorId));
1429
1507
  }
1430
1508
  const busy = connection.session.descriptor?.turn_state === 'busy';
1431
1509
  return {
@@ -55,6 +55,7 @@ export declare class ChatHostControls {
55
55
  readonly baseUrl: string;
56
56
  private readonly credential;
57
57
  state: HostState;
58
+ private updating;
58
59
  private constructor();
59
60
  static fromEnvironment(env?: NodeJS.ProcessEnv): Promise<ChatHostControls | undefined>;
60
61
  refresh(): Promise<void>;
@@ -67,6 +68,9 @@ export declare class ChatHostControls {
67
68
  arguments: string[];
68
69
  env?: Record<string, string>;
69
70
  }>;
71
+ /** Publish complete host replies in request order; a late refresh must not
72
+ * overwrite the connection returned by a newer lifecycle operation. */
73
+ private update;
70
74
  private request;
71
75
  }
72
76
  /** Approval policy is captured for one turn, never inferred from connection privileges. */
@@ -3,6 +3,7 @@ export class ChatHostControls {
3
3
  baseUrl;
4
4
  credential;
5
5
  state;
6
+ updating = Promise.resolve();
6
7
  constructor(baseUrl, credential, state) {
7
8
  this.baseUrl = baseUrl;
8
9
  this.credential = credential;
@@ -19,21 +20,28 @@ export class ChatHostControls {
19
20
  host.state = await host.request('/state');
20
21
  return host;
21
22
  }
22
- async refresh() { this.state = await this.request('/state'); }
23
+ async refresh() { await this.update('/state'); }
23
24
  async select(selection) {
24
- const result = await this.request('/select', selection);
25
- this.state = result;
26
- return result;
25
+ return this.update('/select', selection);
27
26
  }
28
- async remember(id, nativeId) { this.state = await this.request('/remember', { id, nativeId }); }
27
+ async remember(id, nativeId) { await this.update('/remember', { id, nativeId }); }
29
28
  async open(id) {
30
- const result = await this.request('/open', { id });
31
- this.state = result;
32
- return result;
29
+ return this.update('/open', { id });
33
30
  }
34
31
  async setup(kind, harness) {
35
32
  return this.request('/setup', { kind, harness });
36
33
  }
34
+ /** Publish complete host replies in request order; a late refresh must not
35
+ * overwrite the connection returned by a newer lifecycle operation. */
36
+ update(path, selection) {
37
+ const pending = this.updating.then(async () => {
38
+ const result = await this.request(path, selection);
39
+ this.state = result;
40
+ return result;
41
+ });
42
+ this.updating = pending.then(() => undefined, () => undefined);
43
+ return pending;
44
+ }
37
45
  async request(path, selection) {
38
46
  const response = await fetch(this.baseUrl + path, {
39
47
  method: selection ? 'POST' : 'GET', headers: { Authorization: `Bearer ${this.credential}`, 'Content-Type': 'application/json' },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@volter/supercode-frontend-vscode",
3
3
  "displayName": "Volter Harness",
4
- "version": "0.1.29",
4
+ "version": "0.1.30",
5
5
  "type": "module",
6
6
  "description": "VS Code chat-view frontend for an existing Volter Harness SDK runtime",
7
7
  "publisher": "volter-ai-dev",