@volter/supercode-frontend-vscode 0.1.15 → 0.1.17

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/README.md CHANGED
@@ -11,6 +11,88 @@ package or npm.
11
11
 
12
12
  ## Native chat controls
13
13
 
14
+ The optional host-controls state can include `setup: { ready, actions, reason,
15
+ cwd }`. Each action supplies `kind` (`login` or `install`), `harness`, `name` and
16
+ the exact `command` shown before execution. Chat renders native buttons, with
17
+ **Sign in with ChatGPT** for Codex and the reported name for other harnesses.
18
+ Clicking asks the host's authenticated `/setup` door to revalidate `{ kind,
19
+ harness }` and return the action plus a structured `{ program, arguments, cwd, env? }`
20
+ terminal launch. Absolute executables and an optional host-authored PATH keep
21
+ the terminal on the same installation and discovery path as the host's probe.
22
+ It runs directly as the integrated terminal's process, without typing a shell
23
+ command; credentials and
24
+ browser interaction belong to the harness. Failed or cancelled processes can be
25
+ retried, and closing a terminal never counts as successful authentication.
26
+
27
+ While setup is not ready, Chat refreshes `/state` every three seconds and exposes
28
+ **Check again**. When the host returns a ready runtime connection, it attaches
29
+ and reveals that conversation without a reload. Polling only finishes after both
30
+ attachment and the host's native reveal command succeed for that same session
31
+ and runtime URL. A transient attach/reveal failure stays pending and is retried;
32
+ **Check again** retries that handoff even when the host already reports ready.
33
+ Changed setup resources refresh
34
+ Code-OSS's cached status history after an install. Hosts without these optional
35
+ fields keep the existing runtime-attachment behavior.
36
+
37
+ `starterPrompts?: {label: string; prompt: string}[]` is project data supplied by
38
+ the host. An eligible new conversation exposes at most three short command links
39
+ in the default participant's native welcome content, outside the transcript.
40
+ The host's `fillInputCommand` receives the exact native session resource and full
41
+ prompt and must return `{sessionResource, input}` after filling that existing
42
+ widget, without submitting. A missing or mismatched receipt is an error.
43
+ The untargeted partial-query command is not used. Links never send or write a turn.
44
+ Presentation and clicks use the same `starterEligibility` calculation; runtime
45
+ and host state changes update the welcome message, and a user turn clears it.
46
+ Native Chat also hides its welcome surface immediately when a request is added.
47
+ Eligibility requires a successful attachment, an idle runtime, no pending request,
48
+ no saved/canonical non-system messages or truncated saved history, and no observed
49
+ user turn. Accepted-input events (`user_message`, `turn_started`) and busy snapshots
50
+ record turn evidence that survives re-attachment and replay eviction. Setup/status
51
+ events can advance cursors without disqualifying a new conversation; system-only
52
+ history also stays eligible. The checks run again after
53
+ project data loads and before filling; a failed attachment never offers starters.
54
+ `supercode.frontend.status(true)` includes each connection's `starterEligibility`:
55
+ attachment state, turn state, pending count, saved-history presence/truncation/count
56
+ and roles, canonical count/roles, sticky turn evidence, both cursors, replay count
57
+ and kinds, active host ID, host busy state, connection identity check, and rejection
58
+ reasons. `lastStarterAttempt` preserves the exact post-attachment guard snapshot
59
+ and native binding from the latest click. These contain no message bodies or tokens.
60
+ A rejected click names its failed conditions instead of a generic new/idle error.
61
+ The pinned Chat session API exposes immutable history, without withdrawal or
62
+ live enablement of a rendered command part. Starters therefore never enter history.
63
+ `defaultChatParticipant.additionalWelcomeMessage` supplies trusted Markdown with
64
+ only `supercode.frontend.fillStarter` allowed; labels are escaped and command
65
+ arguments are encoded. Hosts without that proposal get no starter offer rather
66
+ than permanent transcript buttons. Existing setup actions keep their native status
67
+ presentation. No new workbench API or editor-side contribution is required.
68
+ The pinned Code-OSS `f16dc165` has no `suggestedPrompts` field in either
69
+ `vscode.proposed.defaultChatParticipant.d.ts` or the internal
70
+ `IChatViewWelcomeContent`. `ChatViewWelcomePart` renders `additionalMessage` as
71
+ Markdown; its optional `firstLinkToButton` converts only one anchor and the
72
+ participant welcome never enables that option. The editor's native overlay can
73
+ render standalone trusted command links as welcome buttons without a new extension
74
+ API; other hosts keep Markdown links. The two introductory lines live together in
75
+ this extension's `package.json`: `chatSessions.welcomeMessage` and
76
+ `supercodeFrontendWelcome.starterIntroduction`. Hosts own their project prompt data.
77
+ If `starterPromptsPending` is true, the project configuration is still loading.
78
+ Empty-conversation resolution refreshes the host until that declaration arrives
79
+ (cancellable, with a 30-second timeout), so native Chat does not cache an empty
80
+ offer before the project adapter loads.
81
+
82
+ W32/W37 passed starter filling and W37 passed healthy refresh with draft retention.
83
+ W44 passed a real boost turn and exposed a presentation defect: transcript starter
84
+ buttons remained after the turn even though the guard rejected them. W50 passed
85
+ the native welcome fix at `29d8fac99`: all three pointer fills, one real HUD turn,
86
+ first-turn disappearance, return-to-idle suppression, and no starters when reopening
87
+ the earlier conversation with seven history entries. The shorter introductory copy
88
+ is source-only; it needs a frontend build plus a workbench overlay copy.
89
+ The accompanying native button and New Session repairs require a Code-OSS compile.
90
+ W50 also found that the generic native New Session action
91
+ bypasses this provider's creation command; its owning host must route that action
92
+ through `chatSessions/newSession` rather than opening an unbound untitled resource.
93
+ Signed-out/install transitions and failed-discovery/attachment recovery remain
94
+ unverified live. A published frontend and rebuilt workbench are needed for npx users.
95
+
14
96
  A bundling host can expose a private lifecycle endpoint through
15
97
  `SUPERCODE_FRONTEND_HOST_URL` and `SUPERCODE_FRONTEND_HOST_CREDENTIAL_FILE`.
16
98
  The endpoint must be loopback HTTP and the credential follows the runtime credential-file
@@ -0,0 +1,6 @@
1
+ /** Native Chat presentation; the host supplies inventory, terminal actions and project data. */
2
+ import * as vscode from 'vscode';
3
+ import type { ChatSetupState, ChatStarterPrompt } from './host-controls.js';
4
+ export declare function setupParts(setup: ChatSetupState): Array<vscode.ChatResponseMarkdownPart | vscode.ChatResponseCommandButtonPart>;
5
+ /** Welcome content is owned by the empty-state renderer, never transcript history. */
6
+ export declare function starterWelcome(prompts: readonly ChatStarterPrompt[], session: string, resource: string, introduction: string): vscode.MarkdownString;
@@ -0,0 +1,29 @@
1
+ /** Native Chat presentation; the host supplies inventory, terminal actions and project data. */
2
+ import * as vscode from 'vscode';
3
+ export function setupParts(setup) {
4
+ const parts = [
5
+ new vscode.ChatResponseMarkdownPart('Sign in to use Chat. Complete sign-in in the terminal and browser.'),
6
+ ];
7
+ for (const action of setup.actions) {
8
+ const title = action.kind === 'install' ? `Install ${action.name}`
9
+ : action.harness === 'codex' ? 'Sign in with ChatGPT' : `Sign in with ${action.name}`;
10
+ parts.push(new vscode.ChatResponseMarkdownPart(`Runs: \`${action.command.replace(/`/g, '\\`')}\``));
11
+ parts.push(new vscode.ChatResponseCommandButtonPart({ title, command: 'supercode.frontend.setup', arguments: [action.kind, action.harness] }));
12
+ }
13
+ if (!setup.actions.length) {
14
+ parts.push(new vscode.ChatResponseMarkdownPart(setup.reason || 'No sign-in action is available.'));
15
+ }
16
+ parts.push(new vscode.ChatResponseCommandButtonPart({ title: 'Check again', command: 'supercode.frontend.refreshSetup' }));
17
+ return parts;
18
+ }
19
+ /** Welcome content is owned by the empty-state renderer, never transcript history. */
20
+ export function starterWelcome(prompts, session, resource, introduction) {
21
+ const message = new vscode.MarkdownString().appendText(introduction).appendMarkdown('\n\n');
22
+ message.isTrusted = { enabledCommands: ['supercode.frontend.fillStarter'] };
23
+ for (const [index, starter] of prompts.slice(0, 3).entries()) {
24
+ const args = encodeURIComponent(JSON.stringify([session, index, resource]));
25
+ message.appendMarkdown('[').appendText(starter.label)
26
+ .appendMarkdown(`](command:supercode.frontend.fillStarter?${args})\n\n`);
27
+ }
28
+ return message;
29
+ }
package/dist/extension.js CHANGED
@@ -2,13 +2,14 @@ import { resolveSessionBinding } from './bindings.js';
2
2
  import { withTextContext } from './context.js';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { ChatHostControls, runtimeIdentity, harnessDisplayName, turnPermission } from './host-controls.js';
5
+ import { setupParts, starterWelcome } from './chat-setup.js';
5
6
  import * as vscode from 'vscode';
6
7
  import { createFrontendClient } from './client.js';
7
8
  import { readCredentialFile } from './credential.js';
8
9
  import { grantedProposals, PROPOSAL_EFFECTS, REQUESTED_PROPOSALS } from './proposals.js';
9
10
  import { observeActiveResponse, sessionStatus } from './native-session.js';
10
11
  import { RequestNotPendingError, VscodeFrontendSession } from './session.js';
11
- import { fence, acceptedDecisions, opaqueText, projectEvent, projectPendingRequests, projectHistory, projectReplayTurns, resolveParts, resolutionSentence, typedAnswer, } from './view.js';
12
+ import { acceptedDecisions, opaqueText, projectEvent, projectPendingRequests, projectHistory, projectReplayTurns, resolveParts, resolutionSentence, typedAnswer, } from './view.js';
12
13
  const SESSION_SCHEME = 'supercode';
13
14
  const PARTICIPANT_ID = 'supercode.chat';
14
15
  const DEFAULT_PARTICIPANT_ID = 'supercode.chat.default';
@@ -32,6 +33,44 @@ const ENV = Object.freeze({
32
33
  */
33
34
  unavailable: 'SUPERCODE_FRONTEND_UNAVAILABLE',
34
35
  });
36
+ /** Transport progress includes setup events; it is not a user-turn count. */
37
+ function conversationEligibility(connection) {
38
+ const session = connection.session;
39
+ const inputs = {
40
+ attachmentState: session.connection,
41
+ turnState: session.descriptor?.turn_state ?? null,
42
+ pendingRequestCount: session.pendingRequests.size,
43
+ historyTruncated: connection.historyTruncated ?? false,
44
+ persistedHistoryLoaded: connection.persistedHistory !== undefined,
45
+ persistedHistoryCount: connection.persistedHistory?.length ?? 0,
46
+ persistedHistoryRoles: connection.persistedHistory?.map(message => message.role) ?? [],
47
+ canonicalHistoryCount: session.history.length,
48
+ canonicalHistoryRoles: session.history.map(message => message.role),
49
+ hasUserTurn: session.hasUserTurn,
50
+ historyCursor: session.historyCursor,
51
+ eventCursor: session.cursor,
52
+ replayLength: session.replay.length,
53
+ replayKinds: [...new Set(session.replay.map(event => event.kind))],
54
+ nativeEventKinds: [...new Set(session.replay.filter(event => event.kind === 'native_event')
55
+ .map(event => event.payload?.kind).filter(kind => typeof kind === 'string'))],
56
+ };
57
+ const reasons = [];
58
+ if (inputs.attachmentState !== 'connected')
59
+ reasons.push(`attachmentState=${inputs.attachmentState}`);
60
+ if (inputs.turnState !== 'idle')
61
+ reasons.push(`turnState=${inputs.turnState}`);
62
+ if (inputs.pendingRequestCount)
63
+ reasons.push(`pendingRequestCount=${inputs.pendingRequestCount}`);
64
+ if (inputs.historyTruncated)
65
+ reasons.push('saved history is truncated');
66
+ if (inputs.persistedHistoryRoles.some(role => role !== 'system'))
67
+ reasons.push('saved history contains a turn');
68
+ if (inputs.canonicalHistoryRoles.some(role => role !== 'system'))
69
+ reasons.push('canonical history contains a turn');
70
+ if (inputs.hasUserTurn)
71
+ reasons.push('a user turn has been observed');
72
+ return { ...inputs, eligible: reasons.length === 0, reasons };
73
+ }
35
74
  function normalizePermissions(value, fallback) {
36
75
  const raw = Array.isArray(value)
37
76
  ? value.map(String)
@@ -324,9 +363,57 @@ export async function activate(context) {
324
363
  const boundId = (resource) => resolveSessionBinding(resource, controls?.state.sessions ?? [], resourceBindings);
325
364
  let applying = Promise.resolve();
326
365
  const frontends = new SupercodeFrontends();
366
+ const starterEligibility = (connection) => {
367
+ const conversation = conversationEligibility(connection);
368
+ const activeSession = controls?.state.activeSession ?? null;
369
+ const hostBusy = Boolean(controls?.state.busy);
370
+ const connectionMatches = frontends.get(connection.id) === connection;
371
+ const reasons = [...conversation.reasons];
372
+ if (!controls)
373
+ reasons.push('host controls unavailable');
374
+ if (activeSession !== connection.id)
375
+ reasons.push(`activeSession=${activeSession}; expected ${connection.id}`);
376
+ if (hostBusy)
377
+ reasons.push('hostBusy=true');
378
+ if (!connectionMatches)
379
+ reasons.push('connection was replaced');
380
+ return { ...conversation, controlsAvailable: Boolean(controls), connectionId: connection.id, activeSession, hostBusy, connectionMatches,
381
+ eligible: reasons.length === 0, reasons };
382
+ };
383
+ const lastStarterAttempts = new Map();
384
+ // Runtimes attached by address ("Set Runtime Token", "Attach to Runtime")
385
+ // beside a host's managed conversation. The host's catalogue does not know
386
+ // them, so they open as what they are: attachments, shown as their runtime
387
+ // reports them, and never taken for the conversation the host manages.
388
+ const attachedIds = new Set();
389
+ const managed = () => frontends.all.find((connection) => !attachedIds.has(connection.id));
390
+ // The attached runtime the person is looking at: the Chat names its harness, not the host's.
391
+ let focusedAttached;
392
+ const welcomeParticipants = new Set();
393
+ const welcomeValues = new WeakMap();
394
+ const starterResources = new Map();
395
+ const updateStarterWelcome = () => {
396
+ if (!granted.has('defaultChatParticipant'))
397
+ return;
398
+ const connection = managed();
399
+ const prompts = controls?.state.starterPrompts ?? [];
400
+ // This is the click guard's exact state, including host activity and connection
401
+ // identity. A later idle state must not revive an offer after a user turn.
402
+ const message = connection && !focusedAttached && !controls?.state.starterPromptsPending
403
+ && prompts.length && starterEligibility(connection).eligible
404
+ ? starterWelcome(prompts, connection.id, starterResources.get(connection.id) ?? sessionResource(connection).toString(), context.extension.packageJSON.supercodeFrontendWelcome.starterIntroduction)
405
+ : new vscode.MarkdownString('');
406
+ for (const participant of welcomeParticipants) {
407
+ if (welcomeValues.get(participant) === message.value)
408
+ continue;
409
+ participant.additionalWelcomeMessage = message;
410
+ welcomeValues.set(participant, message.value);
411
+ }
412
+ };
413
+ context.subscriptions.push(frontends.onDidChange(updateStarterWelcome), controlsChanged.event(updateStarterWelcome));
327
414
  let approvalAvailable = false;
328
415
  context.subscriptions.push(frontends.onDidChange(() => {
329
- const active = frontends.get(undefined);
416
+ const active = managed();
330
417
  const available = Boolean(active?.session.descriptor?.actions.respond && active.session.permissions.has('approve'));
331
418
  if (available !== approvalAvailable) {
332
419
  approvalAvailable = available;
@@ -334,7 +421,9 @@ export async function activate(context) {
334
421
  }
335
422
  }));
336
423
  const identity = () => {
337
- const descriptor = frontends.get(undefined)?.session.descriptor;
424
+ if (focusedAttached)
425
+ return runtimeIdentity(focusedAttached.session.descriptor?.source_harness, focusedAttached.session.descriptor?.model);
426
+ const descriptor = managed()?.session.descriptor;
338
427
  return runtimeIdentity(controls?.state.selection.harness ?? descriptor?.source_harness, controls?.state.selection.model || controls?.state.actualModel || descriptor?.model);
339
428
  };
340
429
  const icon = () => {
@@ -382,9 +471,13 @@ export async function activate(context) {
382
471
  throw new Error('The requested conversation has no runtime connection.');
383
472
  const id = controls.state.activeSession;
384
473
  const existing = frontends.get(id);
385
- if (existing && existing.baseUrl === env.SUPERCODE_FRONTEND_URL)
474
+ if (existing && existing.baseUrl === env.SUPERCODE_FRONTEND_URL && existing.session.connection !== 'closed') {
475
+ if (existing.session.connection !== 'connected')
476
+ await existing.session.attach();
386
477
  return existing;
387
- await frontends.disconnect();
478
+ }
479
+ for (const connection of frontends.all.filter((entry) => !attachedIds.has(entry.id)))
480
+ await frontends.disconnect(connection.id);
388
481
  const connection = await frontends.connect({ connectionId: id, baseUrl: env.SUPERCODE_FRONTEND_URL,
389
482
  token: await readCredentialFile(env.SUPERCODE_FRONTEND_CREDENTIAL_FILE), clientId: env.SUPERCODE_FRONTEND_CLIENT_ID,
390
483
  permissions: normalizePermissions(env.SUPERCODE_FRONTEND_PERMISSIONS, ['observe']), label: identity().name }, log);
@@ -392,12 +485,124 @@ export async function activate(context) {
392
485
  throw new Error('Could not connect to the requested conversation.');
393
486
  return connection;
394
487
  };
488
+ let setupHandoff;
489
+ const setupHandoffComplete = () => {
490
+ const id = controls?.state.activeSession;
491
+ const connection = id ? frontends.get(id) : undefined;
492
+ return Boolean(setupHandoff && setupHandoff.id === id
493
+ && setupHandoff.baseUrl === controls?.state.connection?.SUPERCODE_FRONTEND_URL
494
+ && connection?.baseUrl === setupHandoff.baseUrl && connection.session.connection === 'connected');
495
+ };
395
496
  const revealManaged = async () => {
396
497
  const id = controls?.state.activeSession;
397
498
  if (!id || !controls?.state.openSessionCommand)
398
499
  throw new Error('The host cannot reveal this conversation.');
399
500
  await vscode.commands.executeCommand(controls.state.openSessionCommand, vscode.Uri.from({ scheme: SESSION_SCHEME, authority: id, path: '/conversation' }).toString());
501
+ const connection = frontends.get(id);
502
+ if (controls.state.activeSession !== id || !connection || connection.session.connection !== 'connected'
503
+ || connection.baseUrl !== controls.state.connection?.SUPERCODE_FRONTEND_URL) {
504
+ throw new Error('The conversation changed or disconnected before setup could reveal it.');
505
+ }
506
+ // Host readiness alone is not completion: the native reveal must succeed too.
507
+ setupHandoff = { id, baseUrl: connection.baseUrl };
508
+ };
509
+ let setupFingerprint = '';
510
+ const revealSetup = async () => {
511
+ if (!controls?.state.setup || controls.state.setup.ready || !controls.state.openSessionCommand)
512
+ return;
513
+ const fingerprint = JSON.stringify(controls.state.setup);
514
+ if (setupFingerprint === fingerprint)
515
+ return;
516
+ setupFingerprint = fingerprint;
517
+ // A changed resource avoids Code-OSS reusing cached status history after an install.
518
+ try {
519
+ await vscode.commands.executeCommand(controls.state.openSessionCommand, vscode.Uri.from({ scheme: SESSION_SCHEME, path: UNAVAILABLE_PATH, query: encodeURIComponent(fingerprint) }).toString());
520
+ }
521
+ catch (error) {
522
+ setupFingerprint = '';
523
+ throw error;
524
+ }
400
525
  };
526
+ let refreshingSetup;
527
+ const refreshSetup = () => {
528
+ refreshingSetup ??= (async () => {
529
+ if (!controls)
530
+ return;
531
+ await controls.refresh();
532
+ if (controls.state.setup?.ready) {
533
+ await connectManaged();
534
+ controlsChanged.fire();
535
+ if (!setupHandoffComplete())
536
+ await revealManaged();
537
+ }
538
+ else {
539
+ setupHandoff = undefined;
540
+ controlsChanged.fire();
541
+ await revealSetup();
542
+ }
543
+ })().finally(() => { refreshingSetup = undefined; });
544
+ return refreshingSetup;
545
+ };
546
+ const setupTerminals = new Map();
547
+ context.subscriptions.push(vscode.window.onDidCloseTerminal(terminal => {
548
+ for (const [key, value] of setupTerminals)
549
+ if (value === terminal)
550
+ setupTerminals.delete(key);
551
+ }));
552
+ context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.refreshSetup', refreshSetup));
553
+ context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.setup', async (kind, harness) => {
554
+ if (!controls)
555
+ throw new Error('This host does not expose sign-in.');
556
+ const key = `${kind}/${harness}`;
557
+ const existing = setupTerminals.get(key);
558
+ if (existing && existing.exitStatus === undefined) {
559
+ existing.show();
560
+ return;
561
+ }
562
+ await controls.refresh();
563
+ const offered = controls.state.setup?.actions.find(a => a.kind === kind && a.harness === harness);
564
+ if (!offered) {
565
+ await refreshSetup();
566
+ throw new Error('This setup action is no longer available.');
567
+ }
568
+ const action = await controls.setup(kind, harness);
569
+ if (action.command !== offered.command)
570
+ throw new Error('The setup command changed. Check again before running it.');
571
+ const terminal = vscode.window.createTerminal({ name: `${action.name} · ${kind === 'login' ? 'sign in' : 'install'}`,
572
+ cwd: action.cwd, shellPath: action.program, shellArgs: action.arguments, env: action.env });
573
+ setupTerminals.set(key, terminal);
574
+ terminal.show();
575
+ }));
576
+ context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.fillStarter', async (session, index, nativeResource) => {
577
+ if (!controls || !Number.isInteger(index) || index < 0 || index > 2)
578
+ throw new Error('Invalid starter selection.');
579
+ const connection = frontends.get(session);
580
+ if (!connection)
581
+ throw new Error(`Starter prompt unavailable: connection ${session} is absent.`);
582
+ const resource = nativeResource ? vscode.Uri.parse(nativeResource)
583
+ : vscode.Uri.from({ scheme: SESSION_SCHEME, authority: session, path: '/conversation' });
584
+ if (boundId(resource) !== session)
585
+ throw new Error('The starter has no matching conversation binding.');
586
+ await connection.session.attach();
587
+ const guard = starterEligibility(connection);
588
+ lastStarterAttempts.set(session, { at: new Date().toISOString(), resource: resource.toString(),
589
+ boundSession: boundId(resource), guard });
590
+ while (lastStarterAttempts.size > 50)
591
+ lastStarterAttempts.delete(lastStarterAttempts.keys().next().value);
592
+ if (!guard.eligible)
593
+ throw new Error(`Starter prompt unavailable: ${guard.reasons.join('; ')}.`);
594
+ const prompt = controls.state.starterPrompts?.[index]?.prompt;
595
+ if (!prompt)
596
+ throw new Error('This starter is no longer available.');
597
+ if (!controls.state.fillInputCommand)
598
+ throw new Error('This host does not support filling a conversation draft.');
599
+ // The host addresses the existing native widget, never the default participant or last focus.
600
+ const receipt = await vscode.commands.executeCommand(controls.state.fillInputCommand, resource.toString(), prompt);
601
+ if (!receipt || receipt.sessionResource !== resource.toString() || receipt.input !== prompt) {
602
+ throw new Error('Chat did not confirm the requested draft.');
603
+ }
604
+ return receipt;
605
+ }));
401
606
  const connectionFor = async (resource) => {
402
607
  if (!controls) {
403
608
  const connection = frontends.get(resource.authority);
@@ -405,10 +610,17 @@ export async function activate(context) {
405
610
  throw new Error('This conversation is no longer connected.');
406
611
  return connection;
407
612
  }
613
+ const attached = attachedIds.has(resource.authority) ? frontends.get(resource.authority) : undefined;
614
+ if (focusedAttached !== attached) {
615
+ focusedAttached = attached;
616
+ controlsChanged.fire();
617
+ }
618
+ if (attached)
619
+ return attached;
408
620
  const id = boundId(resource);
409
621
  if (!id)
410
622
  throw new Error('This chat has no backend session binding. Start a new chat; no message was sent.');
411
- const active = frontends.get(undefined);
623
+ const active = managed();
412
624
  if (active && active.id !== id && (active.session.descriptor?.turn_state === 'busy' || active.session.pendingRequests.size))
413
625
  throw new Error('Finish or cancel the active turn before opening another conversation.');
414
626
  try {
@@ -432,7 +644,7 @@ export async function activate(context) {
432
644
  async function applySelection(selection, openChat = true) {
433
645
  if (!controls)
434
646
  throw new Error('This host does not expose harness or model configuration.');
435
- const active = frontends.get(undefined);
647
+ const active = managed();
436
648
  if (active?.session.descriptor?.turn_state === 'busy' || active?.session.pendingRequests.size)
437
649
  throw new Error('Finish or cancel the current turn before starting a new chat.');
438
650
  await controls.select(selection);
@@ -454,7 +666,7 @@ export async function activate(context) {
454
666
  currentOptions,
455
667
  options: (resource) => {
456
668
  const id = resource ? boundId(resource) : undefined;
457
- const active = resource ? (id ? frontends.get(id) : undefined) : frontends.get(undefined);
669
+ const active = resource ? (id ? frontends.get(id) : undefined) : managed();
458
670
  const canApprove = active?.session.descriptor?.actions.respond && active.session.permissions.has('approve');
459
671
  const permissionItems = [{ id: 'ask', name: canApprove ? 'Ask for approval' : 'Harness permissions', description: canApprove ? 'Show each approval requested by the harness.' : 'Approval controls are unavailable for this harness.' },
460
672
  ...((canApprove || (!active && resource && currentOptions(resource).permissions === 'autoApprove')) ? [{ id: 'autoApprove', name: 'Auto approve', description: 'Allow tool approval requests for this chat. Harness sandbox rules still apply.' }] : [])];
@@ -479,8 +691,28 @@ export async function activate(context) {
479
691
  return connection;
480
692
  },
481
693
  resolve: connectionFor,
694
+ attached: (connection) => attachedIds.has(connection.id),
482
695
  catalogue: () => controls?.state.sessions ?? [],
483
696
  bindings: boundId,
697
+ setup: () => controls?.state.setup,
698
+ prepareStarters: async (session, resource, token) => {
699
+ // Project configuration can arrive after the runtime connects. Wait for
700
+ // its real answer before publishing the conversation's welcome actions.
701
+ const deadline = Date.now() + 30_000;
702
+ await controls?.refresh();
703
+ while (controls?.state.starterPromptsPending) {
704
+ if (token.isCancellationRequested)
705
+ throw new vscode.CancellationError();
706
+ if (Date.now() >= deadline)
707
+ throw new Error('Project configuration is still loading. Reopen Chat once the project is ready.');
708
+ await new Promise(resolve => setTimeout(resolve, 250));
709
+ await controls.refresh();
710
+ }
711
+ if (token.isCancellationRequested)
712
+ throw new vscode.CancellationError();
713
+ starterResources.set(session, resource);
714
+ controlsChanged.fire();
715
+ },
484
716
  };
485
717
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.activateSession', async (resource) => { await connectionFor(vscode.Uri.parse(resource)); }));
486
718
  context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.openSession', async (id) => {
@@ -578,7 +810,7 @@ export async function activate(context) {
578
810
  }
579
811
  finally {
580
812
  cancellation.dispose();
581
- if (controls) {
813
+ if (controls && !attachedIds.has(connection.id)) {
582
814
  if (connection.session.descriptor?.session_id)
583
815
  await controls.remember(connection.id, connection.session.descriptor.session_id).catch(error => log.warn(`Could not save conversation identity: ${String(error)}`));
584
816
  await controls.refresh().catch(error => log.warn(`Could not refresh chat identity: ${String(error)}`));
@@ -589,10 +821,15 @@ export async function activate(context) {
589
821
  const handler = async (request, chatContext, stream, token) => {
590
822
  await applying;
591
823
  const requestedResource = chatContext.chatSessionContext?.chatSessionItem?.resource;
824
+ if (controls?.state.setup && !controls.state.setup.ready) {
825
+ for (const part of setupParts(controls.state.setup))
826
+ stream.push(part);
827
+ return {};
828
+ }
592
829
  const connection = requestedResource
593
830
  ? await connectionFor(requestedResource)
594
- : controls ? undefined : frontends.get(undefined);
595
- if (controls && connection && request.model?.vendor === 'supercode-runtime') {
831
+ : controls ? undefined : managed();
832
+ if (controls && connection && !attachedIds.has(connection.id) && request.model?.vendor === 'supercode-runtime') {
596
833
  const selection = controls.state.sessions.find(s => s.id === connection.id).selection;
597
834
  const expected = `${selection.harness}/${encodeURIComponent(selection.model || '__default__')}`;
598
835
  if (request.model.id !== expected || (typeof request.modelConfiguration?.reasoningEffort === 'string' && request.modelConfiguration.reasoningEffort !== selection.effort)) {
@@ -638,6 +875,8 @@ export async function activate(context) {
638
875
  const participant = vscode.chat.createChatParticipant(id, handler);
639
876
  participant.iconPath = icon();
640
877
  participants.push(participant);
878
+ welcomeParticipants.add(participant);
879
+ updateStarterWelcome();
641
880
  context.subscriptions.push(participant);
642
881
  }
643
882
  catch (error) {
@@ -652,7 +891,7 @@ export async function activate(context) {
652
891
  // canDelegate is disabled to avoid a generic workbench-owned registration.
653
892
  // A participant names the harness; the native model picker names the model.
654
893
  // Keep the author stable when a model changes during request dispatch.
655
- const participantName = () => harnessDisplayName(controls?.state.selection.harness ?? frontends.get(undefined)?.session.descriptor?.source_harness);
894
+ const participantName = () => harnessDisplayName(focusedAttached?.session.descriptor?.source_harness ?? controls?.state.selection.harness ?? managed()?.session.descriptor?.source_harness);
656
895
  const participantKey = () => `${participantName()}/${identity().provider ?? ''}`;
657
896
  const makeParticipant = () => {
658
897
  const current = { name: participantName() };
@@ -660,6 +899,8 @@ export async function activate(context) {
660
899
  ? vscode.chat.createDynamicChatParticipant(SESSION_SCHEME, { name: current.name.replace(/[^a-zA-Z0-9]/g, ''), fullName: current.name, publisherName: 'Volter', description: 'Ask a question or describe a task' }, handler)
661
900
  : vscode.chat.createChatParticipant(SESSION_SCHEME, handler);
662
901
  participant.iconPath = icon();
902
+ welcomeParticipants.add(participant);
903
+ updateStarterWelcome();
663
904
  return participant;
664
905
  };
665
906
  let sessionParticipant = makeParticipant();
@@ -668,6 +909,7 @@ export async function activate(context) {
668
909
  if (participantKey() === previousIdentity)
669
910
  return;
670
911
  previousIdentity = participantKey();
912
+ welcomeParticipants.delete(sessionParticipant);
671
913
  sessionParticipant.dispose();
672
914
  sessionParticipant = makeParticipant();
673
915
  for (const p of participants)
@@ -769,7 +1011,16 @@ export async function activate(context) {
769
1011
  if (controls)
770
1012
  await controls.refresh();
771
1013
  if (inspect === true)
772
- return { connections: frontends.all.map(c => ({ ...frontends.info(c), permissions: [...c.session.permissions], pendingRequests: [...c.session.pendingRequests.values()] })), selection: controls?.state.selection, activeSession: controls?.state.activeSession, busy: Boolean(controls?.state.busy || frontends.all.some(c => c.session.descriptor?.turn_state === 'busy' || c.session.pendingRequests.size)), sessions: controls?.state.sessions, permission: currentOptions().permissions, identity: identity() };
1014
+ return {
1015
+ connections: frontends.all.map(c => ({ ...frontends.info(c), permissions: [...c.session.permissions],
1016
+ pendingRequests: [...c.session.pendingRequests.values()], starterEligibility: starterEligibility(c),
1017
+ lastStarterAttempt: lastStarterAttempts.get(c.id) ?? null })),
1018
+ selection: controls?.state.selection, activeSession: controls?.state.activeSession,
1019
+ busy: Boolean(controls?.state.busy || frontends.all.some(c => c.session.descriptor?.turn_state === 'busy' || c.session.pendingRequests.size)),
1020
+ setupHandoff: { hostReady: controls?.state.setup?.ready ?? null, complete: setupHandoffComplete(),
1021
+ revealed: setupHandoff ?? null },
1022
+ sessions: controls?.state.sessions, permission: currentOptions().permissions, identity: identity(),
1023
+ };
773
1024
  const rows = frontends.all.map((connection) => {
774
1025
  const info = frontends.info(connection);
775
1026
  return `${info.label} · ${info.baseUrl} · ${info.model ?? 'unknown model'} · ${info.turnState ?? 'unattached'}`;
@@ -792,11 +1043,16 @@ export async function activate(context) {
792
1043
  log.info('Volter Harness: a base URL is configured but no token is stored; run Volter Harness: Set Runtime Token.');
793
1044
  return;
794
1045
  }
795
- await frontends
1046
+ const connection = await frontends
796
1047
  .connect({ baseUrl, token, permissions: normalizePermissions(settings.get('permissions'), ['observe']) }, log)
797
1048
  .catch((error) => {
798
1049
  log.error(`Volter Harness could not attach to ${baseUrl}: ${String(error?.message ?? error)}`);
1050
+ return undefined;
799
1051
  });
1052
+ if (controls && connection) {
1053
+ attachedIds.add(connection.id);
1054
+ controlsChanged.fire();
1055
+ }
800
1056
  }
801
1057
  /**
802
1058
  * The handover path: a runtime (or a product that launched this editor's
@@ -851,6 +1107,10 @@ export async function activate(context) {
851
1107
  if (!granted.has('chatSessionsProvider'))
852
1108
  return;
853
1109
  if (controls) {
1110
+ if (controls.state.setup && !controls.state.setup.ready) {
1111
+ await revealSetup();
1112
+ return;
1113
+ }
854
1114
  try {
855
1115
  await revealManaged();
856
1116
  }
@@ -882,8 +1142,21 @@ export async function activate(context) {
882
1142
  // NOT awaited: this opens an editor, and activation must return before the
883
1143
  // workbench will run one. Awaiting it here left activation hanging and the
884
1144
  // panel on "Loading…" — observed.
885
- const opening = setTimeout(() => void openResumedSession(), 500);
1145
+ const opening = setTimeout(() => void openResumedSession().catch(error => log.warn(`Could not reveal Chat: ${String(error)}`)), 500);
886
1146
  context.subscriptions.push({ dispose: () => clearTimeout(opening) });
1147
+ // Retry until host readiness has completed both attachment AND native reveal.
1148
+ // A transient failure in either step leaves the handoff pending, even if ready=true.
1149
+ let setupTimer;
1150
+ let setupDisposed = false;
1151
+ const pollSetup = () => {
1152
+ if (setupDisposed || !controls?.state.setup || (controls.state.setup.ready && setupHandoffComplete()))
1153
+ return;
1154
+ setupTimer = setTimeout(() => {
1155
+ void refreshSetup().catch(error => log.warn(`Could not refresh sign-in: ${String(error)}`)).finally(pollSetup);
1156
+ }, 3000);
1157
+ };
1158
+ pollSetup();
1159
+ context.subscriptions.push({ dispose: () => { setupDisposed = true; clearTimeout(setupTimer); } });
887
1160
  return {
888
1161
  connect: async (options) => {
889
1162
  const connection = await frontends.connect(options, log);
@@ -903,10 +1176,79 @@ export function deactivate() {
903
1176
  // Every disposable is owned by the extension context.
904
1177
  }
905
1178
  /**
906
- * One rendered part as the markdown a HISTORY turn carries. A replayed turn is
907
- * built from `ChatResponseMarkdownPart`s only — the live part types belong to a
908
- * running stream — so a tool row becomes its own one-line summary rather than
909
- * the JSON of the object that would have drawn it.
1179
+ * A tool call reaches a turn as two parts, its start and its completion, which
1180
+ * a live stream draws as one row because the editor merges them by
1181
+ * `toolCallId`. A HISTORY turn is markdown and merges nothing, so each call
1182
+ * showed twice ("Tool call: exec" and again with its output). The start keeps
1183
+ * its place and its description (the command it ran); the completion gives it
1184
+ * its output and outcome.
1185
+ */
1186
+ function oneRowPerToolCall(parts) {
1187
+ const rows = [];
1188
+ const started = new Map();
1189
+ for (const part of parts) {
1190
+ if (part.part === 'tool' && part.toolCallId) {
1191
+ const at = started.get(part.toolCallId);
1192
+ const first = at === undefined ? undefined : rows[at];
1193
+ if (first && first.part === 'tool' && part.isComplete) {
1194
+ rows[at] = {
1195
+ ...first,
1196
+ ...part,
1197
+ pastTenseMessage: first.invocationMessage ?? part.pastTenseMessage,
1198
+ invocationMessage: first.invocationMessage ?? part.invocationMessage,
1199
+ };
1200
+ continue;
1201
+ }
1202
+ if (!part.isComplete)
1203
+ started.set(part.toolCallId, rows.length);
1204
+ }
1205
+ rows.push(part);
1206
+ }
1207
+ return rows;
1208
+ }
1209
+ /**
1210
+ * A saved turn's parts, drawn as the live stream draws them. The editor
1211
+ * converts a history turn's response with the same converter as a live
1212
+ * response (`convertResponseTurn` maps every part through it), so a tool call
1213
+ * is the same `ChatToolInvocationPart` row it was while it ran; only the text
1214
+ * between tools is markdown, consecutive text joined into one part. A turn
1215
+ * looks the same however late the Chat was opened.
1216
+ */
1217
+ function historyParts(parts) {
1218
+ const out = [];
1219
+ let text = [];
1220
+ const flush = () => {
1221
+ if (text.length)
1222
+ out.push(new vscode.ChatResponseMarkdownPart(text.join('\n\n')));
1223
+ text = [];
1224
+ };
1225
+ for (const part of parts) {
1226
+ if (part.part === 'tool') {
1227
+ flush();
1228
+ const toolName = part.toolName || 'tool';
1229
+ const invocation = new vscode.ChatToolInvocationPart(toolName, part.toolCallId);
1230
+ invocation.invocationMessage = part.invocationMessage || toolName;
1231
+ invocation.pastTenseMessage = part.pastTenseMessage || part.invocationMessage || toolName;
1232
+ invocation.isConfirmed = true;
1233
+ invocation.isComplete = part.isComplete;
1234
+ if (part.isError !== undefined)
1235
+ invocation.isError = part.isError;
1236
+ if (part.isComplete) {
1237
+ invocation.toolSpecificData = { input: part.inputText ?? '', output: part.resultText ?? '' };
1238
+ }
1239
+ out.push(invocation);
1240
+ continue;
1241
+ }
1242
+ const value = historyText(part);
1243
+ if (value)
1244
+ text.push(value);
1245
+ }
1246
+ flush();
1247
+ return out.length ? out : [new vscode.ChatResponseMarkdownPart('')];
1248
+ }
1249
+ /**
1250
+ * One text part as the markdown a HISTORY turn carries (tool calls are drawn
1251
+ * as tool rows by `historyParts`).
910
1252
  */
911
1253
  function historyText(part) {
912
1254
  switch (part.part) {
@@ -921,17 +1263,9 @@ function historyText(part) {
921
1263
  return part.value;
922
1264
  case 'reference':
923
1265
  return `\`${part.path}\``;
924
- case 'tool': {
925
- // No `<details>` here: a HISTORY turn's markdown is rendered without raw
926
- // HTML, so the tags show up as text — observed. A replayed tool is one
927
- // line plus its output, fenced.
928
- const summary = part.pastTenseMessage ?? part.invocationMessage ?? part.toolName;
929
- if (!summary)
930
- return '';
931
- const verb = part.isError ? 'Tool failed:' : 'Tool call:';
932
- const result = part.resultText ? `\n\n${fence(part.resultText)}` : '';
933
- return `${verb} \`${summary}\`${result}`;
934
- }
1266
+ case 'tool':
1267
+ // Drawn as a tool row by `historyParts`.
1268
+ return '';
935
1269
  case 'answers':
936
1270
  return `**${part.title}**\n\n${part.message}`;
937
1271
  }
@@ -968,7 +1302,7 @@ function registerSessions(context, frontends, participant, granted, handler, con
968
1302
  const unavailableItem = () => {
969
1303
  if (!frontends.unavailable || frontends.all.length > 0)
970
1304
  return undefined;
971
- const item = controller.createChatSessionItem(unavailableResource(), 'Volter Harness · no runtime');
1305
+ const item = controller.createChatSessionItem(unavailableResource(), controls?.setup() ? 'Sign in to Chat' : 'Volter Harness · no runtime');
972
1306
  item.description = frontends.unavailable.split('\n')[0];
973
1307
  item.status = vscode.ChatSessionStatus.Failed;
974
1308
  return item;
@@ -1037,10 +1371,10 @@ function registerSessions(context, frontends, participant, granted, handler, con
1037
1371
  // the session opens, with no request behind it and nothing to wait
1038
1372
  // for — which is the whole point, since the person has not typed.
1039
1373
  return {
1040
- title: 'Volter Harness · no runtime',
1374
+ title: controls?.setup() ? 'Sign in to Chat' : 'Volter Harness · no runtime',
1041
1375
  history: [
1042
1376
  new turns.ChatRequestTurn(STATUS_MARKER_PROMPT, undefined, [], participant.id, []),
1043
- new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart(unavailableMarkdown(frontends.unavailable))], {}, participant.id),
1377
+ new turns.ChatResponseTurn(controls?.setup() ? setupParts(controls.setup()) : [new vscode.ChatResponseMarkdownPart(unavailableMarkdown(frontends.unavailable))], {}, participant.id),
1044
1378
  ],
1045
1379
  requestHandler: boundHandler,
1046
1380
  };
@@ -1051,7 +1385,7 @@ function registerSessions(context, frontends, participant, granted, handler, con
1051
1385
  if (!connection) {
1052
1386
  return { history: [], requestHandler: boundHandler };
1053
1387
  }
1054
- if (controls) {
1388
+ if (controls && !controls.attached(connection)) {
1055
1389
  controls.setPermission(resource, inputState.groups.find(group => group.id === 'permissions')?.selected?.id);
1056
1390
  inputState.groups = controls.options(resource);
1057
1391
  }
@@ -1060,8 +1394,9 @@ function registerSessions(context, frontends, participant, granted, handler, con
1060
1394
  // `state.history`), so the copy taken when this window attached is
1061
1395
  // stale as soon as a turn runs. The generated client reuses the
1062
1396
  // already-open event stream, so this is a snapshot, not a reconnect.
1063
- await connection.session.attach().catch(() => {
1397
+ const attached = await connection.session.attach().then(() => true).catch(() => {
1064
1398
  // An unreachable runtime replays what this window last saw.
1399
+ return false;
1065
1400
  });
1066
1401
  // The attach snapshot IS the session's history: the runtime's own
1067
1402
  // canonical transcript, replayed as turns.
@@ -1097,16 +1432,18 @@ function registerSessions(context, frontends, participant, granted, handler, con
1097
1432
  history.push(new turns.ChatRequestTurn(turn.prompt ?? '', undefined, [], participant.id, []));
1098
1433
  continue;
1099
1434
  }
1100
- const markdown = (turn.parts ?? [])
1101
- .map(historyText)
1102
- .filter(Boolean)
1103
- .join('\n\n');
1104
- history.push(new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart(markdown)], {}, participant.id));
1435
+ history.push(new turns.ChatResponseTurn(historyParts(oneRowPerToolCall(turn.parts ?? [])), {}, participant.id));
1436
+ }
1437
+ // The public API cannot withdraw a rendered history command part. Keep
1438
+ // starters in the default participant's native welcome content instead:
1439
+ // Chat hides that whole surface as soon as the first request is added.
1440
+ if (attached && history.length === 0 && !controls?.attached(connection) && controls) {
1441
+ await controls.prepareStarters(connection.id, resource.toString(), _token);
1105
1442
  }
1106
1443
  const busy = connection.session.descriptor?.turn_state === 'busy';
1107
1444
  return {
1108
1445
  title: connection.label,
1109
- options: controls?.currentOptions(resource),
1446
+ options: controls?.attached(connection) ? undefined : controls?.currentOptions(resource),
1110
1447
  history,
1111
1448
  requestHandler: boundHandler,
1112
1449
  activeResponseCallback: busy
@@ -10,6 +10,10 @@ export interface ChatChoice {
10
10
  name: string;
11
11
  description?: string;
12
12
  }
13
+ export interface ChatStarterPrompt {
14
+ label: string;
15
+ prompt: string;
16
+ }
13
17
  export interface ManagedChatSession {
14
18
  id: string;
15
19
  selection: ChatSelection;
@@ -17,11 +21,28 @@ export interface ManagedChatSession {
17
21
  title: string;
18
22
  created: number;
19
23
  }
24
+ /** Host-owned operations. The frontend displays commands before the person runs them. */
25
+ export interface ChatSetupAction {
26
+ kind: 'login' | 'install';
27
+ harness: string;
28
+ name: string;
29
+ command: string;
30
+ }
31
+ export interface ChatSetupState {
32
+ ready: boolean;
33
+ actions: ChatSetupAction[];
34
+ reason: string | null;
35
+ cwd: string;
36
+ }
20
37
  export interface HostState {
21
38
  history?: ChatMessage[];
22
39
  historyTruncated?: boolean;
23
40
  activeSession: string | null;
24
41
  openSessionCommand?: string;
42
+ /** Fill the composer of an exact native session; returns {sessionResource, input}. */
43
+ fillInputCommand?: string;
44
+ /** The native renderer supports a leading response with no visible user request. */
45
+ responseOnlyHistory?: boolean;
25
46
  sessions: ManagedChatSession[];
26
47
  selection: ChatSelection;
27
48
  connection?: Record<string, string>;
@@ -31,6 +52,10 @@ export interface HostState {
31
52
  busy: boolean;
32
53
  actualModel?: string | null;
33
54
  configurable: boolean;
55
+ setup?: ChatSetupState;
56
+ starterPrompts?: ChatStarterPrompt[];
57
+ /** The host's project configuration has not finished loading yet. */
58
+ starterPromptsPending?: boolean;
34
59
  }
35
60
  export interface HostSelectionResult extends HostState {
36
61
  connection: Record<string, string>;
@@ -45,6 +70,12 @@ export declare class ChatHostControls {
45
70
  select(selection: ChatSelection): Promise<HostSelectionResult>;
46
71
  remember(id: string, nativeId: string): Promise<void>;
47
72
  open(id: string): Promise<HostSelectionResult>;
73
+ setup(kind: ChatSetupAction['kind'], harness: string): Promise<ChatSetupAction & {
74
+ cwd: string;
75
+ program: string;
76
+ arguments: string[];
77
+ env?: Record<string, string>;
78
+ }>;
48
79
  private request;
49
80
  }
50
81
  /** Approval policy is captured for one turn, never inferred from connection privileges. */
@@ -31,6 +31,9 @@ export class ChatHostControls {
31
31
  this.state = result;
32
32
  return result;
33
33
  }
34
+ async setup(kind, harness) {
35
+ return this.request('/setup', { kind, harness });
36
+ }
34
37
  async request(path, selection) {
35
38
  const response = await fetch(this.baseUrl + path, {
36
39
  method: selection ? 'POST' : 'GET', headers: { Authorization: `Bearer ${this.credential}`, 'Content-Type': 'application/json' },
package/dist/session.d.ts CHANGED
@@ -77,6 +77,8 @@ export declare class VscodeFrontendSession {
77
77
  history: ChatMessage[];
78
78
  historyCursor: number;
79
79
  cursor: number;
80
+ /** Semantic evidence, retained even when replay is evicted or re-attached. */
81
+ hasUserTurn: boolean;
80
82
  /**
81
83
  * Every event this attachment has seen, in order — NOT just the last
82
84
  * snapshot's. A re-attach asks `afterSequence: cursor`, so its `replay` is
package/dist/session.js CHANGED
@@ -44,6 +44,8 @@ export class VscodeFrontendSession {
44
44
  history = [];
45
45
  historyCursor = 0;
46
46
  cursor = 0;
47
+ /** Semantic evidence, retained even when replay is evicted or re-attached. */
48
+ hasUserTurn = false;
47
49
  /**
48
50
  * Every event this attachment has seen, in order — NOT just the last
49
51
  * snapshot's. A re-attach asks `afterSequence: cursor`, so its `replay` is
@@ -103,6 +105,8 @@ export class VscodeFrontendSession {
103
105
  descriptor: structuredClone(snapshot.descriptor),
104
106
  pendingRequests: new Map([...this.pendingRequests].map(([id, request]) => [id, structuredClone(request)])),
105
107
  cursor: Math.max(this.cursor, snapshot.history_cursor),
108
+ hasUserTurn: this.hasUserTurn || snapshot.descriptor.turn_state === 'busy'
109
+ || snapshot.history.some(message => message.role !== 'system'),
106
110
  };
107
111
  // Validate and project the whole attachment before any observable field
108
112
  // moves or a live listener is registered.
@@ -125,6 +129,7 @@ export class VscodeFrontendSession {
125
129
  this.appendTranscript(structuredClone(snapshot.replay));
126
130
  this.pendingRequests = staged.pendingRequests;
127
131
  this.cursor = staged.cursor;
132
+ this.hasUserTurn = staged.hasUserTurn;
128
133
  this.connection = 'connected';
129
134
  this.attachedAtMs ??= Date.now();
130
135
  if (!this.#subscribed) {
@@ -252,6 +257,11 @@ export class VscodeFrontendSession {
252
257
  throw new EventSequenceGapError(state.cursor + 1, event.sequence);
253
258
  }
254
259
  state.cursor = event.sequence;
260
+ // Hosted runtimes publish user_message + turn_started when accepting input.
261
+ // Native init/status notifications also consume sequence numbers, but are
262
+ // not user turns. Keep this independent of the bounded replay buffer.
263
+ if (BUSY_KINDS.has(event.kind))
264
+ state.hasUserTurn = true;
255
265
  const payload = (event.payload ?? {});
256
266
  if (event.kind === 'request' && payload.type === 'request') {
257
267
  const request = payload.request;
package/dist/view.js CHANGED
@@ -639,6 +639,9 @@ export function projectReplayTurns(events, granted, context) {
639
639
  */
640
640
  export function projectHistory(history, granted, pendingToolNames = new Set()) {
641
641
  const turns = [];
642
+ // Keep results inside the native invocation, as the live stream does. A
643
+ // separate Markdown result bypasses the editor's collapsed tool-step group.
644
+ const toolRows = new Map();
642
645
  const pushAssistant = (parts) => {
643
646
  const last = turns[turns.length - 1];
644
647
  if (last && last.role === 'assistant' && last.parts) {
@@ -659,6 +662,7 @@ export function projectHistory(history, granted, pendingToolNames = new Set()) {
659
662
  for (const message of history) {
660
663
  const body = messageText(message);
661
664
  if (message.role === 'user') {
665
+ toolRows.clear();
662
666
  turns.push({ role: 'user', prompt: clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters) });
663
667
  continue;
664
668
  }
@@ -674,14 +678,21 @@ export function projectHistory(history, granted, pendingToolNames = new Set()) {
674
678
  pushAssistant([{ part: 'markdown', value: clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters) }]);
675
679
  }
676
680
  for (const call of message.tool_calls ?? []) {
681
+ const subject = toolSubject(call.function.arguments);
682
+ const label = clamp(stripControls(subject ? `${call.function.name} · ${subject}` : call.function.name), VSCODE_CHAT_LIMITS.titleCharacters);
683
+ const row = {
684
+ part: 'tool',
685
+ toolName: call.function.name,
686
+ toolCallId: call.id,
687
+ invocationMessage: label,
688
+ pastTenseMessage: label,
689
+ inputText: clamp(stripControls(parseArguments(call.function.arguments)), VSCODE_CHAT_LIMITS.partCharacters),
690
+ isComplete: true,
691
+ };
692
+ if (call.id)
693
+ toolRows.set(call.id, row);
677
694
  pushAssistant(resolveParts([{
678
- emit: {
679
- part: 'tool',
680
- toolName: call.function.name,
681
- toolCallId: call.id,
682
- invocationMessage: clamp(call.function.name, VSCODE_CHAT_LIMITS.titleCharacters),
683
- isComplete: true,
684
- },
695
+ emit: row,
685
696
  needs: 'chatParticipantAdditions',
686
697
  fallback: [{
687
698
  part: 'markdown',
@@ -696,10 +707,43 @@ export function projectHistory(history, granted, pendingToolNames = new Set()) {
696
707
  // interruption. Only the final matching tool is affected, never old history.
697
708
  const pending = message === history.at(-1) && pendingToolNames.has(message.name ?? '')
698
709
  && body === '[no tool result recorded — turn interrupted]';
710
+ if (pending) {
711
+ // The pinned editor serializes history tool parts as complete even if
712
+ // isComplete=false. Keep this explicit marker for matched AND orphan
713
+ // results; the active response separately restores the approval controls.
714
+ pushAssistant([{ part: 'markdown', value: '**Awaiting approval** — no tool result yet.' }]);
715
+ continue;
716
+ }
717
+ if (granted.has('chatParticipantAdditions')) {
718
+ const row = message.tool_call_id ? toolRows.get(message.tool_call_id) : undefined;
719
+ const output = clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters);
720
+ if (row) {
721
+ row.resultText = output;
722
+ // Canonical ChatMessage has no general error flag. Only this known
723
+ // synthetic marker establishes interruption; do not guess from prose.
724
+ if (body === '[no tool result recorded — turn interrupted]')
725
+ row.isError = true;
726
+ toolRows.delete(message.tool_call_id);
727
+ }
728
+ else {
729
+ // A bounded transcript can retain a result whose call was omitted.
730
+ // Preserve it as an inspectable tool row without inventing its input.
731
+ const label = clamp(stripControls(message.name || 'Tool result'), VSCODE_CHAT_LIMITS.titleCharacters);
732
+ pushAssistant([{
733
+ part: 'tool',
734
+ toolName: message.name || 'tool',
735
+ toolCallId: message.tool_call_id || `history-result-${history.indexOf(message)}`,
736
+ invocationMessage: label,
737
+ pastTenseMessage: label,
738
+ isComplete: true,
739
+ resultText: output,
740
+ }]);
741
+ }
742
+ continue;
743
+ }
699
744
  pushAssistant([{
700
745
  part: 'markdown',
701
- value: pending ? '**Awaiting approval** — no tool result yet.'
702
- : `**Tool result${message.name ? ` · ${message.name}` : ''}**\n\n${fence(clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters))}`,
746
+ value: `**Tool result${message.name ? ` · ${message.name}` : ''}**\n\n${fence(clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters))}`,
703
747
  }]);
704
748
  }
705
749
  if (turns.length <= VSCODE_CHAT_LIMITS.historyTurns)
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.15",
4
+ "version": "0.1.17",
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",
@@ -84,7 +84,7 @@
84
84
  "description": "Conversations with your AI assistant",
85
85
  "icon": "$(server-process)",
86
86
  "welcomeTitle": "Chat",
87
- "welcomeMessage": "Ask a question or continue a saved conversation.",
87
+ "welcomeMessage": "Write a message to your coding agent.",
88
88
  "inputPlaceholder": "Ask a question or describe a task",
89
89
  "order": 1,
90
90
  "capabilities": {
@@ -191,6 +191,9 @@
191
191
  "@types/vscode": "1.138.0",
192
192
  "typescript": "5.6.3"
193
193
  },
194
+ "supercodeFrontendWelcome": {
195
+ "starterIntroduction": "Choose an idea to edit before sending:"
196
+ },
194
197
  "supercodeFrontendContract": {
195
198
  "name": "@volter/supercode-frontend",
196
199
  "version": "0.2.7",