@volter/supercode-frontend-vscode 0.1.48 → 0.1.49

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
@@ -267,17 +267,30 @@ function writePart(stream, part, state) {
267
267
  const toolName = part.toolName || state.toolNames.get(part.toolCallId) || 'tool';
268
268
  if (part.toolName)
269
269
  state.toolNames.set(part.toolCallId, part.toolName);
270
+ // The titles a call started with stay its titles: a completion (which may not name its tool) never renames it.
271
+ const started = state.toolTitles.get(part.toolCallId);
272
+ const titles = started ?? { invocation: part.invocationMessage || `Running \`${toolName}\``, pastTense: part.pastTenseMessage || part.invocationMessage || `Ran \`${toolName}\`` };
273
+ if (!started)
274
+ state.toolTitles.set(part.toolCallId, titles);
275
+ if (part.commandLine)
276
+ state.toolCommands.set(part.toolCallId, part.commandLine);
277
+ const commandLine = state.toolCommands.get(part.toolCallId);
270
278
  const invocation = new vscode.ChatToolInvocationPart(toolName, part.toolCallId);
271
279
  invocation.enablePartialUpdate = true;
272
- invocation.invocationMessage = part.invocationMessage || toolName;
280
+ invocation.invocationMessage = new vscode.MarkdownString(titles.invocation);
273
281
  invocation.isConfirmed = true;
274
282
  invocation.isComplete = part.isComplete;
275
- invocation.pastTenseMessage = part.pastTenseMessage || toolName;
283
+ invocation.pastTenseMessage = new vscode.MarkdownString(titles.pastTense);
276
284
  if (part.isError !== undefined)
277
285
  invocation.isError = part.isError;
278
286
  if (part.inputText !== undefined)
279
287
  state.toolInputs.set(part.toolCallId, part.inputText);
280
- if (part.isComplete) {
288
+ if (commandLine) {
289
+ // VS Code's own terminal block: the command, and its output once it has run.
290
+ invocation.toolSpecificData = { commandLine: { original: commandLine }, language: 'sh',
291
+ ...(part.isComplete ? { output: { text: part.resultText ?? '' } } : {}) };
292
+ }
293
+ else if (part.isComplete) {
281
294
  invocation.toolSpecificData = {
282
295
  input: state.toolInputs.get(part.toolCallId) ?? '',
283
296
  output: part.resultText ?? '',
@@ -494,14 +507,28 @@ export async function activate(context) {
494
507
  controlsChanged.fire();
495
508
  }
496
509
  }));
510
+ // What a new chat of `selection` runs with: its own picks over what the harness's configuration names (host state).
511
+ const draftEffective = (selection) => {
512
+ const configured = controls?.state.effectiveByHarness?.[selection.harness];
513
+ if (!configured)
514
+ return null;
515
+ const picked = selection.model ? controls?.state.modelsByHarness?.[selection.harness]?.find(model => model.id === selection.model) : undefined;
516
+ return { model: selection.model || configured.model, modelName: selection.model ? picked?.name ?? selection.model : configured.modelName, effort: selection.effort || configured.effort };
517
+ };
518
+ const effectiveNow = () => {
519
+ if (focusedAttached)
520
+ return null;
521
+ const selection = draftSelection();
522
+ return selection ? draftEffective(selection) : controls?.state.effective ?? null;
523
+ };
497
524
  const identity = () => {
498
525
  if (focusedAttached)
499
526
  return runtimeIdentity(focusedAttached.session.descriptor?.source_harness, focusedAttached.session.descriptor?.model);
500
527
  const descriptor = managed()?.session.descriptor;
501
528
  const selection = draftSelection();
502
529
  if (selection)
503
- return runtimeIdentity(selection.harness, selection.model);
504
- return runtimeIdentity(controls?.state.selection.harness ?? descriptor?.source_harness, controls?.state.selection.model || controls?.state.actualModel || descriptor?.model);
530
+ return runtimeIdentity(selection.harness, selection.model, draftEffective(selection));
531
+ return runtimeIdentity(controls?.state.selection.harness ?? descriptor?.source_harness, controls?.state.selection.model || controls?.state.actualModel || descriptor?.model, controls?.state.effective);
505
532
  };
506
533
  const icon = () => {
507
534
  const provider = identity().provider;
@@ -524,16 +551,18 @@ export async function activate(context) {
524
551
  if (!choices.some(model => model.id === selection.model))
525
552
  choices.push({ id: selection.model, name: selection.model });
526
553
  modelSelections.clear();
554
+ // Named by what the conversation actually runs with, never "default model" (host `effective`).
555
+ const effective = draft ? draftEffective(selection) : controls.state.effective;
527
556
  return choices.map(model => {
528
557
  const id = `${selection.harness}/${encodeURIComponent(model.id || '__default__')}`;
529
558
  modelSelections.set(id, { ...selection, model: model.id });
530
- const provider = runtimeIdentity(selection.harness, selection.model).provider;
531
- const name = model.id ? model.name : runtimeIdentity(selection.harness, model.id).name;
559
+ const provider = runtimeIdentity(selection.harness, selection.model, effective).provider;
560
+ const name = runtimeIdentity(selection.harness, model.id, effective).name;
532
561
  return { id, name, family: selection.harness, version: '1',
533
562
  statusIcon: new vscode.ThemeIcon(provider === 'anthropic' ? 'claude' : provider === 'openai' ? 'openai' : provider === 'google' ? 'google-gemini' : 'sparkle'),
534
563
  maxInputTokens: 0, maxOutputTokens: 0, capabilities: {},
535
564
  targetChatSessionType: SESSION_SCHEME, isUserSelectable: true, isDefault: model.id === selection.model,
536
- tooltip: `${name} via ${harnessDisplayName(selection.harness)}${selection.effort ? ` · ${selection.effort} reasoning` : ''}. Model is fixed for this conversation. Use Model settings to choose model and reasoning for a new conversation.`,
565
+ tooltip: `${name}. Model is fixed for this conversation. Use Model settings to choose model and reasoning for a new conversation.`,
537
566
  };
538
567
  });
539
568
  },
@@ -571,7 +600,7 @@ export async function activate(context) {
571
600
  await frontends.disconnect(connection.id);
572
601
  const connection = await frontends.connect({ connectionId: id, baseUrl: env.SUPERCODE_FRONTEND_URL,
573
602
  token: await readCredentialFile(env.SUPERCODE_FRONTEND_CREDENTIAL_FILE), clientId: env.SUPERCODE_FRONTEND_CLIENT_ID,
574
- permissions: normalizePermissions(env.SUPERCODE_FRONTEND_PERMISSIONS, ['observe']), label: runtimeIdentity(state.selection.harness, state.selection.model || state.actualModel).name }, log);
603
+ permissions: normalizePermissions(env.SUPERCODE_FRONTEND_PERMISSIONS, ['observe']), label: runtimeIdentity(state.selection.harness, state.selection.model || state.actualModel, state.effective).name }, log);
575
604
  if (!connection)
576
605
  throw new Error('Could not connect to the requested conversation.');
577
606
  return connection;
@@ -842,7 +871,10 @@ export async function activate(context) {
842
871
  const pickModelAndEffort = async (harness, current) => {
843
872
  const choices = controls?.state.modelsByHarness?.[harness] ?? [{ id: '', name: 'Harness default' }];
844
873
  const name = harnessDisplayName(harness);
845
- const items = [...choices.map(model => ({ label: model.name, description: [model.id, model.description].filter(Boolean).join(' · ') || undefined, id: model.id, efforts: model.efforts })),
874
+ // The harness's own choice is named by what it resolves to (host `effectiveByHarness`).
875
+ const configured = controls?.state.effectiveByHarness?.[harness];
876
+ const items = [...choices.map(model => ({ label: model.id === '' && configured?.modelName ? `${model.name}: ${configured.modelName}` : model.name,
877
+ description: [model.id, model.description].filter(Boolean).join(' · ') || undefined, id: model.id, efforts: model.efforts })),
846
878
  { label: 'Enter an exact model ID…', description: undefined, id: '__custom__', efforts: choices.find(model => model.id === '')?.efforts }];
847
879
  const picked = await pickWithCurrent(items, `New chat · ${name} model`, 'Model availability depends on your harness account.', current?.model ?? '');
848
880
  if (!picked)
@@ -855,8 +887,8 @@ export async function activate(context) {
855
887
  model = typed.trim();
856
888
  }
857
889
  const levels = picked.efforts ?? ['low', 'medium', 'high'];
858
- const efforts = [{ label: 'Harness default', id: '' }, ...levels.map(id => ({ label: id, id }))];
859
- const effort = levels.length === 0 ? efforts[0] : await pickWithCurrent(efforts, `New chat · ${name} reasoning effort`, model ? `For ${model}` : 'For the harness default model', current?.effort ?? '');
890
+ const efforts = [{ label: configured?.effort && !model ? `Harness default: ${configured.effort}` : 'Harness default', id: '' }, ...levels.map(id => ({ label: id, id }))];
891
+ const effort = levels.length === 0 ? efforts[0] : await pickWithCurrent(efforts, `New chat · ${name} reasoning effort`, `For ${model || configured?.modelName || `${name}'s configured model`}`, current?.effort ?? '');
860
892
  if (!effort)
861
893
  return;
862
894
  return { model, effort: effort.id };
@@ -958,7 +990,9 @@ export async function activate(context) {
958
990
  // whose built-in model picker deliberately omits Manage Models. Do not advertise
959
991
  // hot swapping: the command configures a separate native blank chat.
960
992
  // A locked selected item hides the entire native dropdown, including commands.
961
- const item = { id: 'settings', name: 'Model settings' };
993
+ // The model picker names the model and effort and the permissions picker the approval mode; this item is only
994
+ // the way to choose them for a new chat.
995
+ const item = { id: 'settings', name: 'Change model…', description: 'Choose model and reasoning for a new chat.' };
962
996
  groups.push({ id: 'modelSettings', name: 'Model', items: [item], selected: item,
963
997
  commands: [{ command: 'supercode.frontend.configureModel', title: 'Choose model and reasoning for a new chat…', arguments: [selected.id] }] });
964
998
  }
@@ -990,6 +1024,7 @@ export async function activate(context) {
990
1024
  resolve: (resource, token) => connectionFor(resource, false, token),
991
1025
  attached: (connection) => attachedIds.has(connection.id),
992
1026
  catalogue: () => controls?.state.sessions ?? [],
1027
+ effective: (entry) => controls && entry.id === controls.state.activeSession ? controls.state.effective ?? null : draftEffective(entry.selection),
993
1028
  bindings: boundId,
994
1029
  watchApprovals: (connection, stream, token) => watchApprovals(connection, stream, token, () => approvalFor(connection.id)),
995
1030
  };
@@ -1121,6 +1156,59 @@ export async function activate(context) {
1121
1156
  });
1122
1157
  }));
1123
1158
  context.subscriptions.push({ dispose: () => frontends.dispose() });
1159
+ // Auto approve holds for the conversation, not only while a response of this window is open. A harness also runs
1160
+ // turns of its own (a background task reporting back, a Monitor's event): an approval those raise had no response to
1161
+ // answer it or show its card, and the turn waited forever. With no turn of this window running, such an approval is
1162
+ // answered here under the conversation's mode; under Ask it keeps waiting for the person, as before.
1163
+ const answerOutsideTurns = () => {
1164
+ for (const connection of frontends.all) {
1165
+ if (attachedIds.has(connection.id) || runningTurns.get(connection.id)?.size || !canAnswer(connection))
1166
+ continue;
1167
+ if (approvalFor(connection.id) !== 'autoApprove')
1168
+ continue;
1169
+ const answered = answeredRequests.get(connection.id) ?? new Set();
1170
+ answeredRequests.set(connection.id, answered);
1171
+ for (const pending of connection.session.pendingRequests.values()) {
1172
+ if (pending.kind !== 'approval' || answered.has(pending.id))
1173
+ continue;
1174
+ const decisions = acceptedDecisions(pending);
1175
+ if (decisions && !decisions.includes('allow'))
1176
+ continue;
1177
+ answered.add(pending.id);
1178
+ void connection.session.respond({ kind: 'approval', request_id: pending.id, decision: 'allow' }).catch(error => {
1179
+ answered.delete(pending.id);
1180
+ log.warn(`Auto approve could not answer request #${pending.id} outside a turn: ${String(error?.message ?? error)}`);
1181
+ });
1182
+ }
1183
+ }
1184
+ };
1185
+ context.subscriptions.push(frontends.onDidChange(answerOutsideTurns));
1186
+ // STOP ANSWERS WHAT THE AGENT IS WAITING ON FIRST. An agent blocked on an approval (or a question) does not hear an
1187
+ // interrupt until that request is answered: Claude Code's permission prompt kept the turn alive, the request
1188
+ // vanished from view on reload, and every later prompt queued behind it (2026-10-07). So a Stop denies each waiting
1189
+ // approval and cancels each question, then interrupts.
1190
+ const stopConnection = async (connection) => {
1191
+ if (canAnswer(connection)) {
1192
+ for (const pending of [...connection.session.pendingRequests.values()]) {
1193
+ const answer = pending.kind === 'approval'
1194
+ ? { kind: 'approval', request_id: pending.id, decision: 'deny' }
1195
+ : { kind: pending.kind === 'elicitation' ? 'elicitation' : 'other', request_id: pending.id, action: 'cancel' };
1196
+ await connection.session.respond(answer).catch(error => log.warn(`Stop could not answer request #${pending.id}: ${String(error?.message ?? error)}`));
1197
+ }
1198
+ }
1199
+ await connection.session.interrupt();
1200
+ };
1201
+ // The same Stop for a turn no response of this window holds — one the harness started itself, after a background
1202
+ // task or a Monitor's event — which the Chat's own Stop (it cancels a response) cannot reach.
1203
+ context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.stopTurn', async (connectionId) => {
1204
+ const connection = connectionId ? frontends.get(connectionId) : managed();
1205
+ if (!connection)
1206
+ return { stopped: false, reason: 'no conversation' };
1207
+ await stopConnection(connection).catch(error => log.warn(`Stop could not interrupt: ${String(error?.message ?? error)}`));
1208
+ return { stopped: true, connectionId: connection.id };
1209
+ }));
1210
+ const outsideTurns = setInterval(answerOutsideTurns, 2_000);
1211
+ context.subscriptions.push({ dispose: () => clearInterval(outsideTurns) });
1124
1212
  // Auto-answers a conversation's approvals while a response is written into `stream`: read at every event
1125
1213
  // (`sweep`) and on every picker change for that conversation, so switching to Auto approve mid-turn answers
1126
1214
  // the approvals already waiting and the ones after them. Used by a turn this window runs and by a turn it
@@ -1175,7 +1263,7 @@ export async function activate(context) {
1175
1263
  writeProjected(stream, projectPendingRequests([pending], {
1176
1264
  connectionId: connection.id,
1177
1265
  canRespond: connection.session.descriptor?.actions.respond !== false,
1178
- }), granted, { toolInputs: new Map(), toolNames: new Map() });
1266
+ }), granted, { toolInputs: new Map(), toolNames: new Map(), toolTitles: new Map(), toolCommands: new Map() });
1179
1267
  }
1180
1268
  catch {
1181
1269
  void vscode.window.showWarningMessage(`${connection.label}: ${label} is waiting for approval. Open its chat to answer it.`);
@@ -1212,14 +1300,14 @@ export async function activate(context) {
1212
1300
  if (approval() === 'autoApprove' && !canAnswer(connection)) {
1213
1301
  throw new Error('This harness does not support automatic approval through Chat. Select Harness permissions.');
1214
1302
  }
1215
- const state = { toolInputs: new Map(), toolNames: new Map() };
1303
+ const state = { toolInputs: new Map(), toolNames: new Map(), toolTitles: new Map(), toolCommands: new Map() };
1216
1304
  const projection = {
1217
1305
  connectionId: connection.id,
1218
1306
  pendingRequest: (requestId) => connection.session.pendingRequests.get(requestId) ?? connection.session.resolvedRequests.get(requestId),
1219
1307
  canRespond: connection.session.descriptor?.actions.respond !== false,
1220
1308
  };
1221
1309
  const cancellation = token.onCancellationRequested(() => {
1222
- void connection.session.interrupt().catch(() => {
1310
+ void stopConnection(connection).catch(() => {
1223
1311
  // The runtime may already be idle; interrupting twice is not an error
1224
1312
  // this view reports.
1225
1313
  });
@@ -1250,6 +1338,9 @@ export async function activate(context) {
1250
1338
  let completed = false;
1251
1339
  try {
1252
1340
  await connection.session.runTurn(prompt, (event) => {
1341
+ // A turn the person stopped is marked stopped by VS Code itself; only an interruption from elsewhere is said.
1342
+ if (event.kind === 'turn_interrupted' && token.isCancellationRequested)
1343
+ return;
1253
1344
  writeProjected(stream, projectEvent(event, projection), granted, state);
1254
1345
  watch.sweep();
1255
1346
  });
@@ -1265,13 +1356,14 @@ export async function activate(context) {
1265
1356
  await controls.refresh().catch(error => log.warn(`Could not refresh chat identity: ${String(error)}`));
1266
1357
  controlsChanged.fire();
1267
1358
  }
1268
- // The model the turn ran on: what the harness's own replies name (the host watches them), else what the
1269
- // runtime reports; and the effort it was given.
1270
- const ran = (controls?.state.activeSession === connection.id ? controls.state.actualModel : undefined) || connection.session.descriptor?.model;
1271
- const effort = controls?.state.sessions.find(session => session.id === connection.id)?.selection.effort;
1272
- if (completed && ran && !token.isCancellationRequested)
1273
- stream.markdown(`\n\n_Ran on ${opaqueText(ran, 120)}${effort ? ` · ${opaqueText(effort, 24)} effort` : ''}_`);
1274
1359
  }
1360
+ // The model the turn ran on: what the harness's own replies name (the host watches them), else what the
1361
+ // runtime reports; and the effort it ran at (host `effective`).
1362
+ const active = controls?.state.activeSession === connection.id;
1363
+ const effective = active ? controls?.state.effective : undefined;
1364
+ const ran = effective?.modelName || (active ? controls?.state.actualModel : undefined) || connection.session.descriptor?.model;
1365
+ const effort = effective?.effort || controls?.state.sessions.find(session => session.id === connection.id)?.selection.effort;
1366
+ return completed && ran && !token.isCancellationRequested ? `${opaqueText(ran, 120)}${effort ? ` · ${opaqueText(effort, 24)}` : ''}` : undefined;
1275
1367
  };
1276
1368
  const handler = async (request, chatContext, stream, token) => {
1277
1369
  await applying;
@@ -1306,21 +1398,23 @@ export async function activate(context) {
1306
1398
  // path for the answers no button can carry (`allow for session`, `cancel`,
1307
1399
  // and an elicitation's free-text content).
1308
1400
  const pending = pendingRequestOf(connection);
1401
+ // A message is never taken as an approval: one sent while an approval waits would otherwise allow the tool and
1402
+ // never reach the agent. It is refused, with what to do, like a send during any running turn.
1403
+ if (pending?.kind === 'approval') {
1404
+ throw new Error(`${busyRefusal(connection, 'sending another message') ?? 'The current chat is waiting for your answer.'} Nothing was sent.`);
1405
+ }
1309
1406
  if (pending) {
1407
+ // A question that needs typed content: the message is its answer, and its resolution line says so.
1310
1408
  try {
1311
- const answer = typedAnswer(pending, request.prompt);
1312
- await connection.session.respond(answer);
1313
- stream.markdown(`_answered request #${pending.id}: ${opaqueText(answer, 256)}_`);
1409
+ await connection.session.respond(typedAnswer(pending, request.prompt));
1314
1410
  }
1315
1411
  catch (error) {
1316
- stream.markdown(`Could not answer request #${pending.id}: ${opaqueText(String(error.message), 512)}`);
1412
+ throw new Error(`Could not answer the question: ${opaqueText(String(error.message), 512)}`);
1317
1413
  }
1318
1414
  return {};
1319
1415
  }
1320
- if (request.prompt.trim().length === 0) {
1321
- stream.markdown('_nothing to submit_');
1416
+ if (request.prompt.trim().length === 0)
1322
1417
  return {};
1323
- }
1324
1418
  const native = controls ? undefined : turnPermission(request.permissionLevel);
1325
1419
  const approval = () => native ?? approvalFor(connection.id);
1326
1420
  const attached = [];
@@ -1334,8 +1428,9 @@ export async function activate(context) {
1334
1428
  attached.push({ name: uri.toString(), text: document.getText(location?.range) });
1335
1429
  }
1336
1430
  const prompt = withTextContext(request.prompt, attached);
1337
- await runTurnInto(connection, prompt, stream, token, approval);
1338
- return {};
1431
+ const details = await runTurnInto(connection, prompt, stream, token, approval);
1432
+ // The model the turn ran on, in VS Code's own response footer rather than as a line of the conversation.
1433
+ return details ? { details } : {};
1339
1434
  };
1340
1435
  const participants = [];
1341
1436
  for (const id of granted.has('defaultChatParticipant') ? [PARTICIPANT_ID, DEFAULT_PARTICIPANT_ID] : [PARTICIPANT_ID]) {
@@ -1766,13 +1861,16 @@ function historyParts(parts) {
1766
1861
  flush();
1767
1862
  const toolName = part.toolName || 'tool';
1768
1863
  const invocation = new vscode.ChatToolInvocationPart(toolName, part.toolCallId);
1769
- invocation.invocationMessage = part.invocationMessage || toolName;
1770
- invocation.pastTenseMessage = part.pastTenseMessage || part.invocationMessage || toolName;
1864
+ invocation.invocationMessage = new vscode.MarkdownString(part.invocationMessage || `Ran \`${toolName}\``);
1865
+ invocation.pastTenseMessage = new vscode.MarkdownString(part.pastTenseMessage || part.invocationMessage || `Ran \`${toolName}\``);
1771
1866
  invocation.isConfirmed = true;
1772
1867
  invocation.isComplete = part.isComplete;
1773
1868
  if (part.isError !== undefined)
1774
1869
  invocation.isError = part.isError;
1775
- if (part.isComplete) {
1870
+ if (part.commandLine) {
1871
+ invocation.toolSpecificData = { commandLine: { original: part.commandLine }, language: 'sh', output: { text: part.resultText ?? '' } };
1872
+ }
1873
+ else if (part.isComplete) {
1776
1874
  invocation.toolSpecificData = { input: part.inputText ?? '', output: part.resultText ?? '' };
1777
1875
  }
1778
1876
  out.push(invocation);
@@ -1832,7 +1930,7 @@ function registerSessions(context, frontends, participant, granted, handler, his
1832
1930
  const items = () => {
1833
1931
  return (controls?.catalogue() ?? []).filter(entry => !frontends.get(entry.id)).map(entry => {
1834
1932
  const item = controller.createChatSessionItem(vscode.Uri.from({ scheme: SESSION_SCHEME, authority: entry.id, path: '/conversation' }), entry.title);
1835
- item.description = runtimeIdentity(entry.selection.harness, entry.selection.model).name;
1933
+ item.description = runtimeIdentity(entry.selection.harness, entry.selection.model, controls?.effective(entry)).name;
1836
1934
  item.status = vscode.ChatSessionStatus.Completed;
1837
1935
  item.timing = { created: entry.created };
1838
1936
  return item;
@@ -1974,7 +2072,7 @@ function registerSessions(context, frontends, participant, granted, handler, his
1974
2072
  // already waiting and later ones; Ask leaves them for the person, as before.
1975
2073
  const watch = controls && !controls.attached(connection) ? controls.watchApprovals(connection, stream, token) : undefined;
1976
2074
  try {
1977
- const state = { toolInputs: new Map(), toolNames: new Map(), quiet: watch?.answers };
2075
+ const state = { toolInputs: new Map(), toolNames: new Map(), toolTitles: new Map(), toolCommands: new Map(), quiet: watch?.answers };
1978
2076
  writeProjected(stream, projectPendingRequests(connection.session.pendingRequests.values(), {
1979
2077
  connectionId: connection.id,
1980
2078
  canRespond: connection.session.descriptor?.actions.respond !== false,
@@ -5,6 +5,12 @@ export interface ChatSelection {
5
5
  model: string;
6
6
  effort: string;
7
7
  }
8
+ /** `modelName`: how to name `model` ("Claude Opus 5.5"); `effort` is '' when the model takes none. */
9
+ export interface EffectiveChat {
10
+ model: string;
11
+ modelName: string;
12
+ effort: string;
13
+ }
8
14
  /** `efforts`: the reasoning efforts a model takes, as the host read them from the harness (absent from older hosts). */
9
15
  export interface ChatChoice {
10
16
  id: string;
@@ -51,6 +57,11 @@ export interface HostState {
51
57
  modelsByHarness?: Record<string, ChatChoice[]>;
52
58
  busy: boolean;
53
59
  actualModel?: string | null;
60
+ /** The model and effort this conversation actually runs with, read from the harness's own configuration when the
61
+ * selection names none (and from the runtime once a turn has run). Absent from older hosts. */
62
+ effective?: EffectiveChat | null;
63
+ /** The same for a new chat of each startable harness. */
64
+ effectiveByHarness?: Record<string, EffectiveChat | null>;
54
65
  configurable: boolean;
55
66
  setup?: ChatSetupState;
56
67
  /** The mode a new chat's picker shows. A conversation's own default is its `ManagedChatSession.defaultPermission`;
@@ -85,9 +96,13 @@ export declare class ChatHostControls {
85
96
  /** A picked approval mode, never inferred from connection privileges (those only keep a host default at ask). */
86
97
  export declare function turnPermission(value: string | undefined): 'ask' | 'autoApprove';
87
98
  export declare function harnessDisplayName(harness: string | null | undefined): string;
88
- export declare function runtimeIdentity(harness: string | null | undefined, model: string | null | undefined): {
99
+ /** The Chat's name for a runtime: the model and effort it actually runs with (`effective`, from the host) and its
100
+ * harness. It never says "default model": a harness whose model nobody reports is said to choose its own. */
101
+ export declare function runtimeIdentity(harness: string | null | undefined, model: string | null | undefined, effective?: EffectiveChat | null): {
89
102
  name: string;
90
103
  provider: string | undefined;
91
104
  };
105
+ /** The approval mode as the Chat names it. */
106
+ export declare function permissionName(value: 'ask' | 'autoApprove'): string;
92
107
  /** Native pickers need a nonempty id; the host uses an empty model for its default. */
93
108
  export declare function modelSelection(value: string): string;
@@ -64,14 +64,19 @@ export function turnPermission(value) {
64
64
  export function harnessDisplayName(harness) {
65
65
  return { 'claude-code': 'Claude Code', codex: 'Codex', gemini: 'Gemini CLI', opencode: 'OpenCode', pi: 'Pi', goose: 'Goose' }[harness || ''] || harness || 'Assistant';
66
66
  }
67
- export function runtimeIdentity(harness, model) {
67
+ /** The Chat's name for a runtime: the model and effort it actually runs with (`effective`, from the host) and its
68
+ * harness. It never says "default model": a harness whose model nobody reports is said to choose its own. */
69
+ export function runtimeIdentity(harness, model, effective) {
68
70
  const harnessName = harnessDisplayName(harness);
69
- const knownModel = model && model !== harness ? model : '';
70
- const modelName = { sonnet: 'Claude Sonnet', opus: 'Claude Opus', haiku: 'Claude Haiku' }[knownModel] || knownModel;
71
+ const knownModel = effective?.model || (model && model !== harness ? model : '');
72
+ const modelName = effective?.modelName || { sonnet: 'Claude Sonnet', opus: 'Claude Opus', haiku: 'Claude Haiku' }[knownModel] || knownModel;
71
73
  const provider = /claude|sonnet|opus|haiku/i.test(modelName) || (!modelName && harness === 'claude-code') ? 'anthropic'
72
74
  : /^(gpt|o[134]|chatgpt)|\/gpt/i.test(modelName) || (!modelName && harness === 'codex') ? 'openai'
73
75
  : /gemini/i.test(modelName) || (!modelName && harness === 'gemini') ? 'google' : undefined;
74
- return { name: modelName ? `${modelName} · ${harnessName}` : `${harnessName} · default model`, provider };
76
+ const effort = effective?.effort ? ` · ${effective.effort}` : '';
77
+ return { name: modelName ? `${modelName}${effort}` : `Model chosen by ${harnessName}`, provider };
75
78
  }
79
+ /** The approval mode as the Chat names it. */
80
+ export function permissionName(value) { return value === 'autoApprove' ? 'Auto approve' : 'Ask for approval'; }
76
81
  /** Native pickers need a nonempty id; the host uses an empty model for its default. */
77
82
  export function modelSelection(value) { return value === "__default__" ? "" : value; }
package/dist/session.js CHANGED
@@ -128,8 +128,30 @@ export class VscodeFrontendSession {
128
128
  // none no matter what its replay still carries.
129
129
  // Hosted Codex also receives standalone MCP elicitations. Its host retains
130
130
  // request/resolved pairs without the native broker's turn compaction.
131
- if (staged.descriptor?.turn_state !== 'busy' && staged.descriptor?.source_harness !== 'codex')
132
- staged.pendingRequests.clear();
131
+ // BUT A HARNESS ALSO RUNS TURNS OF ITS OWN (a background task reporting back, a Monitor's event): the runtime then
132
+ // reads idle while the agent loop is blocked on an approval it raised. Clearing that one left the agent waiting
133
+ // forever with nothing to answer it (2026-10-07). Compaction happens only at a turn boundary, so a request raised
134
+ // 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') {
136
+ const BOUNDARIES = new Set(['turn_succeeded', 'turn_completed', 'turn_failed', 'turn_interrupted']);
137
+ let lastBoundary = -1;
138
+ const raisedAt = new Map();
139
+ for (const event of snapshot.replay) {
140
+ if (BOUNDARIES.has(event.kind))
141
+ lastBoundary = Math.max(lastBoundary, event.sequence);
142
+ const payload = (event.payload ?? {});
143
+ const request = event.kind === 'request' ? payload.request : undefined;
144
+ if (request && typeof request.id === 'number')
145
+ raisedAt.set(request.id, event.sequence);
146
+ }
147
+ for (const id of [...staged.pendingRequests.keys()]) {
148
+ const at = raisedAt.get(id);
149
+ // A request known from before this attachment, with no boundary since, is still the open one it was.
150
+ const open = at !== undefined ? at > lastBoundary : lastBoundary < 0 && this.pendingRequests.has(id);
151
+ if (!open)
152
+ staged.pendingRequests.delete(id);
153
+ }
154
+ }
133
155
  this.descriptor = staged.descriptor;
134
156
  this.history = structuredClone(snapshot.history);
135
157
  this.historyCursor = snapshot.history_cursor;
package/dist/view.d.ts CHANGED
@@ -60,6 +60,8 @@ export type ChatPart = {
60
60
  isError?: boolean;
61
61
  isComplete: boolean;
62
62
  resultText?: string;
63
+ /** A shell call's command line: the row is then VS Code's own terminal block, command and output. */
64
+ commandLine?: string;
63
65
  } | {
64
66
  part: 'answers';
65
67
  approval: boolean;
@@ -93,6 +95,18 @@ export declare function diffPaths(diff: string): string[];
93
95
  * wasted row; `write_file · hello.txt` says what it did.
94
96
  */
95
97
  export declare function toolSubject(argumentsText: string): string | undefined;
98
+ /**
99
+ * A tool row's titles the way VS Code's own agent writes them: what the call does, in the present while it runs
100
+ * ("Reading `view.ts`") and the past once done ("Read `view.ts`"), read from the call's own arguments. Shell calls
101
+ * use the description the harness gave them (Claude Code's `Bash.description`) when there is one. A tool this does
102
+ * not know is "Ran `name`", with its subject when the arguments name one.
103
+ */
104
+ export declare function toolTitles(toolName: string, argumentsText: string): {
105
+ invocation: string;
106
+ pastTense: string;
107
+ };
108
+ /** The command line of a shell tool call, read from its own arguments; undefined for any other tool. */
109
+ export declare function shellCommand(toolName: string, argumentsText: string): string | undefined;
96
110
  /** The summary line this view shows for a request payload. */
97
111
  export declare function requestSummary(request: FrontendRequest): string;
98
112
  /** The answers this view offers for a request, one button each. */
@@ -164,6 +178,14 @@ export interface ProjectedTurn {
164
178
  * supercode's own agent loop, and a hosted harness has none. So a resumed
165
179
  * Claude Code session's turns are here or nowhere.
166
180
  */
181
+ /**
182
+ * A "user" message no person typed: Claude Code's `<task-notification>` (a background task reporting back) or the
183
+ * editor's automatic tripwire nudge. Neither is shown as the person's words. A task notification is said as one
184
+ * short line ("Background task completed: <its summary>"); a nudge is not shown. Undefined for a person's message.
185
+ */
186
+ export declare function automaticPrompt(body: string): {
187
+ notice?: string;
188
+ } | undefined;
167
189
  export declare function projectReplayTurns(events: readonly FrontendEvent[], granted: ReadonlySet<string>, context: ProjectionContext): ProjectedTurn[];
168
190
  /**
169
191
  * The attach snapshot's canonical history as chat turns. Bounded: when older
package/dist/view.js CHANGED
@@ -98,6 +98,101 @@ export function toolSubject(argumentsText) {
98
98
  }
99
99
  return undefined;
100
100
  }
101
+ const code = (value) => `\`${value.replace(/`/g, "'")}\``;
102
+ const basename = (path) => path.split(/[\\/]/).filter(Boolean).pop() ?? path;
103
+ /**
104
+ * A tool row's titles the way VS Code's own agent writes them: what the call does, in the present while it runs
105
+ * ("Reading `view.ts`") and the past once done ("Read `view.ts`"), read from the call's own arguments. Shell calls
106
+ * use the description the harness gave them (Claude Code's `Bash.description`) when there is one. A tool this does
107
+ * not know is "Ran `name`", with its subject when the arguments name one.
108
+ */
109
+ export function toolTitles(toolName, argumentsText) {
110
+ let fields = {};
111
+ try {
112
+ const parsed = JSON.parse(argumentsText);
113
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
114
+ fields = parsed;
115
+ }
116
+ catch { /* not JSON: titled by name */ }
117
+ const field = (...names) => {
118
+ for (const name of names) {
119
+ const value = fields[name];
120
+ if (typeof value === 'string' && value.trim())
121
+ return clamp(stripControls(value.trim()), 120);
122
+ if (Array.isArray(value) && value.every(part => typeof part === 'string') && value.length)
123
+ return clamp(stripControls(value.join(' ')), 120);
124
+ }
125
+ return '';
126
+ };
127
+ const both = (present, past) => ({ invocation: clamp(present, VSCODE_CHAT_LIMITS.titleCharacters), pastTense: clamp(past, VSCODE_CHAT_LIMITS.titleCharacters) });
128
+ const file = field('file_path', 'path', 'file', 'notebook_path');
129
+ const name = toolName.toLowerCase();
130
+ if (['bash', 'shell', 'exec_command', 'local_shell', 'powershell'].includes(name)) {
131
+ const description = field('description');
132
+ if (description)
133
+ return both(description, description);
134
+ const command = field('command', 'cmd');
135
+ return command ? both(`Running ${code(command)}`, `Ran ${code(command)}`) : both(`Running a command`, `Ran a command`);
136
+ }
137
+ if (name === 'read' && file) {
138
+ const offset = typeof fields.offset === 'number' ? fields.offset : undefined;
139
+ const limit = typeof fields.limit === 'number' ? fields.limit : undefined;
140
+ const lines = offset !== undefined && limit !== undefined ? `, lines ${offset} to ${offset + limit - 1}` : '';
141
+ return both(`Reading ${code(basename(file))}${lines}`, `Read ${code(basename(file))}${lines}`);
142
+ }
143
+ if (name === 'write' && file)
144
+ return both(`Writing ${code(basename(file))}`, `Wrote ${code(basename(file))}`);
145
+ if (['edit', 'multiedit', 'notebookedit', 'apply_patch'].includes(name))
146
+ return file ? both(`Editing ${code(basename(file))}`, `Edited ${code(basename(file))}`) : both('Editing files', 'Edited files');
147
+ if (name === 'grep') {
148
+ const pattern = field('pattern');
149
+ if (pattern)
150
+ return both(`Searching for ${code(pattern)}`, `Searched for ${code(pattern)}`);
151
+ }
152
+ if (name === 'glob') {
153
+ const pattern = field('pattern');
154
+ if (pattern)
155
+ return both(`Finding files matching ${code(pattern)}`, `Found files matching ${code(pattern)}`);
156
+ }
157
+ if (name === 'webfetch') {
158
+ const url = field('url');
159
+ if (url)
160
+ return both(`Fetching ${url}`, `Fetched ${url}`);
161
+ }
162
+ if (name === 'websearch' || name === 'web_search') {
163
+ const query = field('query');
164
+ if (query)
165
+ return both(`Searching the web for ${code(query)}`, `Searched the web for ${code(query)}`);
166
+ }
167
+ if (['task', 'agent'].includes(name)) {
168
+ const description = field('description');
169
+ if (description)
170
+ return both(`Running a subagent: ${description}`, `Ran a subagent: ${description}`);
171
+ }
172
+ if (name === 'todowrite' || name === 'update_plan')
173
+ return both('Updating the plan', 'Updated the plan');
174
+ // Claude Code names an MCP tool `mcp__<server>__<tool>`; VS Code writes "Ran `tool` – server".
175
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
176
+ if (mcp)
177
+ return both(`Running ${code(mcp[2])} – ${mcp[1]}`, `Ran ${code(mcp[2])} – ${mcp[1]}`);
178
+ const subject = toolSubject(argumentsText);
179
+ return subject ? both(`Running ${code(toolName)} · ${subject}`, `Ran ${code(toolName)} · ${subject}`) : both(`Running ${code(toolName)}`, `Ran ${code(toolName)}`);
180
+ }
181
+ /** The command line of a shell tool call, read from its own arguments; undefined for any other tool. */
182
+ export function shellCommand(toolName, argumentsText) {
183
+ if (!['bash', 'shell', 'exec_command', 'local_shell', 'powershell'].includes(toolName.toLowerCase()))
184
+ return undefined;
185
+ try {
186
+ const fields = JSON.parse(argumentsText);
187
+ const command = fields?.command ?? fields?.cmd;
188
+ if (typeof command === 'string' && command.trim())
189
+ return stripControls(command);
190
+ if (Array.isArray(command) && command.every(part => typeof part === 'string') && command.length)
191
+ return stripControls(command.join(' '));
192
+ }
193
+ catch { /* not JSON */ }
194
+ return undefined;
195
+ }
101
196
  /** The summary line this view shows for a request payload. */
102
197
  export function requestSummary(request) {
103
198
  const payload = request.payload;
@@ -302,21 +397,21 @@ export function projectEvent(event, context) {
302
397
  const toolCallId = text(payload.id, `event-${event.sequence}`);
303
398
  const rawArguments = text(payload.arguments);
304
399
  const inputText = clamp(stripControls(parseArguments(rawArguments)), VSCODE_CHAT_LIMITS.partCharacters);
305
- const subject = toolSubject(rawArguments);
400
+ const titles = toolTitles(toolName, rawArguments);
401
+ const commandLine = shellCommand(toolName, rawArguments);
306
402
  return [{
307
403
  emit: {
308
404
  part: 'tool',
309
405
  toolName,
310
406
  toolCallId,
311
- invocationMessage: clamp(subject ? `${toolName} · ${subject}` : toolName, VSCODE_CHAT_LIMITS.titleCharacters),
407
+ invocationMessage: titles.invocation,
408
+ pastTenseMessage: titles.pastTense,
312
409
  inputText,
313
410
  isComplete: false,
411
+ ...(commandLine ? { commandLine } : {}),
314
412
  },
315
413
  needs: 'chatParticipantAdditions',
316
- fallback: [{
317
- part: 'progress',
318
- value: clamp(subject ? `${toolName} · ${subject}` : toolName, VSCODE_CHAT_LIMITS.titleCharacters),
319
- }],
414
+ fallback: [{ part: 'progress', value: titles.invocation }],
320
415
  }];
321
416
  }
322
417
  // crates/runtime/src/event.rs `AgentEvent::ToolCallCompleted`.
@@ -328,13 +423,16 @@ export function projectEvent(event, context) {
328
423
  const toolCallId = text(payload.id, `event-${event.sequence}`);
329
424
  const output = clamp(stripControls(text(payload.output)), VSCODE_CHAT_LIMITS.partCharacters);
330
425
  const isError = payload.is_error === true;
426
+ // The titles stay the ones the call started with (the writer keeps them per call); a completion that names its
427
+ // tool without having started on this stream is titled by that name.
428
+ const titles = toolName ? toolTitles(toolName, text(payload.arguments)) : undefined;
331
429
  return [{
332
430
  emit: {
333
431
  part: 'tool',
334
432
  toolName,
335
433
  toolCallId,
336
- invocationMessage: clamp(toolName, VSCODE_CHAT_LIMITS.titleCharacters),
337
- pastTenseMessage: clamp(toolName, VSCODE_CHAT_LIMITS.titleCharacters),
434
+ invocationMessage: titles?.invocation ?? '',
435
+ pastTenseMessage: titles?.pastTense ?? '',
338
436
  isError,
339
437
  isComplete: true,
340
438
  resultText: output,
@@ -410,6 +508,10 @@ export function projectEvent(event, context) {
410
508
  case 'request_resolved': {
411
509
  const requestId = typeof payload.request_id === 'number' ? payload.request_id : Number.NaN;
412
510
  const response = payload.response;
511
+ // An allowed approval says nothing more, as in VS Code's own chat: its tool row carries on and is the record.
512
+ // A denial, and an answer to any other request, is said once.
513
+ if (response?.kind === 'approval' && response.decision !== 'deny')
514
+ return [];
413
515
  const sentence = resolutionSentence(response, context.pendingRequest?.(requestId), requestId);
414
516
  return [stable({ part: 'markdown', value: `\n\n_${sentence}_\n\n` })];
415
517
  }
@@ -423,11 +525,9 @@ export function projectEvent(event, context) {
423
525
  }];
424
526
  }
425
527
  // crates/runtime/src/event.rs `AgentEvent::Usage`.
528
+ // Token counts are not conversation: VS Code's own chat keeps them out of the transcript.
426
529
  case 'usage':
427
- return [stable({
428
- part: 'progress',
429
- value: `prompt ${text(payload.prompt_tokens, '?')} · completion ${text(payload.completion_tokens, '?')} · total ${text(payload.total_tokens, '?')} tokens`,
430
- })];
530
+ return [];
431
531
  // crates/runtime/src/event.rs `AgentEvent::BackgroundOutput`.
432
532
  case 'background_output': {
433
533
  const chunk = fence(clamp(stripControls(text(payload.chunk)), VSCODE_CHAT_LIMITS.partCharacters));
@@ -438,8 +538,9 @@ export function projectEvent(event, context) {
438
538
  })];
439
539
  }
440
540
  // crates/harness/src/server.rs `submit_claimed`.
541
+ // The live writer drops it when the person stopped the turn (VS Code marks a stopped response itself).
441
542
  case 'turn_interrupted':
442
- return [stable({ part: 'markdown', value: '\n\n_turn interrupted_\n\n' })];
543
+ return [stable({ part: 'warning', value: 'The turn was interrupted.' })];
443
544
  // crates/harness/src/server.rs `submit_claimed` emits `turn_failed`. The
444
545
  // other four are the error kinds the donor-neutral reader in
445
546
  // crates/frontend-model/src/transcript.rs treats identically:
@@ -555,6 +656,23 @@ function messageText(message) {
555
656
  * supercode's own agent loop, and a hosted harness has none. So a resumed
556
657
  * Claude Code session's turns are here or nowhere.
557
658
  */
659
+ /**
660
+ * A "user" message no person typed: Claude Code's `<task-notification>` (a background task reporting back) or the
661
+ * editor's automatic tripwire nudge. Neither is shown as the person's words. A task notification is said as one
662
+ * short line ("Background task completed: <its summary>"); a nudge is not shown. Undefined for a person's message.
663
+ */
664
+ export function automaticPrompt(body) {
665
+ const trimmed = body.trimStart();
666
+ if (trimmed.startsWith('<task-notification>')) {
667
+ const tag = (name) => clamp(stripControls(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(trimmed)?.[1]?.trim() ?? ''), 200);
668
+ const summary = tag('summary');
669
+ const status = tag('status');
670
+ return { notice: `_Background task${status ? ` ${status}` : ''}${summary ? `: ${summary}` : ''}_` };
671
+ }
672
+ if (trimmed.startsWith('[Volter editor tripwire'))
673
+ return {};
674
+ return undefined;
675
+ }
558
676
  export function projectReplayTurns(events, granted, context) {
559
677
  const turns = [];
560
678
  let streamedText;
@@ -562,6 +680,20 @@ export function projectReplayTurns(events, granted, context) {
562
680
  if (event.kind === 'user_message') {
563
681
  streamedText = undefined;
564
682
  const payload = payloadOf(event);
683
+ const automatic = automaticPrompt(text(payload.text));
684
+ if (automatic) {
685
+ if (!automatic.notice)
686
+ continue;
687
+ if (turns.length === 0)
688
+ turns.push({ role: 'user', prompt: HISTORY_MARKER_PROMPT });
689
+ const last = turns[turns.length - 1];
690
+ const notice = { part: 'markdown', value: `\n\n${automatic.notice}\n\n` };
691
+ if (last.role === 'assistant' && last.parts)
692
+ last.parts.push(notice);
693
+ else
694
+ turns.push({ role: 'assistant', parts: [notice] });
695
+ continue;
696
+ }
565
697
  turns.push({ role: 'user', prompt: clamp(stripControls(text(payload.text)), VSCODE_CHAT_LIMITS.partCharacters) });
566
698
  continue;
567
699
  }
@@ -652,6 +784,12 @@ export function projectHistory(history, granted, pendingToolNames = new Set()) {
652
784
  pushAssistant([{ part: 'markdown', value: clamp(stripControls(text), VSCODE_CHAT_LIMITS.partCharacters) }]);
653
785
  continue;
654
786
  }
787
+ const automatic = automaticPrompt(body);
788
+ if (automatic) {
789
+ if (automatic.notice)
790
+ pushAssistant([{ part: 'markdown', value: `\n\n${automatic.notice}\n\n` }]);
791
+ continue;
792
+ }
655
793
  toolRows.clear();
656
794
  turns.push({ role: 'user', prompt: clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters) });
657
795
  continue;
@@ -675,14 +813,15 @@ export function projectHistory(history, granted, pendingToolNames = new Set()) {
675
813
  pushAssistant([{ part: 'markdown', value: clamp(stripControls(body), VSCODE_CHAT_LIMITS.partCharacters) }]);
676
814
  }
677
815
  for (const call of message.tool_calls ?? []) {
678
- const subject = toolSubject(call.function.arguments);
679
- const label = clamp(stripControls(subject ? `${call.function.name} · ${subject}` : call.function.name), VSCODE_CHAT_LIMITS.titleCharacters);
816
+ const titles = toolTitles(call.function.name, call.function.arguments);
817
+ const commandLine = shellCommand(call.function.name, call.function.arguments);
680
818
  const row = {
681
819
  part: 'tool',
682
820
  toolName: call.function.name,
683
821
  toolCallId: call.id,
684
- invocationMessage: label,
685
- pastTenseMessage: label,
822
+ invocationMessage: titles.invocation,
823
+ pastTenseMessage: titles.pastTense,
824
+ ...(commandLine ? { commandLine } : {}),
686
825
  inputText: clamp(stripControls(parseArguments(call.function.arguments)), VSCODE_CHAT_LIMITS.partCharacters),
687
826
  isComplete: true,
688
827
  };
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.48",
4
+ "version": "0.1.49",
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",