@volter/supercode-frontend-vscode 0.1.50 → 0.1.52

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,74 +11,55 @@ package or npm.
11
11
 
12
12
  ## Native chat controls
13
13
 
14
- When the host has an extension gallery and the workbench install command, setup
15
- also offers **Sign in with ChatGPT** using the official `openai.chatgpt` extension.
16
- Only a click installs it, using `workbench.extensions.installExtension` with
17
- `enable: true` so the workbench waits for registration in the running extension
18
- registry; subsequent clicks reuse it. `chatgpt.openSidebar` then opens its own sign-in view
19
- (the extension does not contribute a public sign-in command). No terminal or
20
- Supercode settings are involved in this path. The choice also appears whenever
21
- the agent picker is shown; it does not interrupt automatic startup or the
22
- sole-local-agent New Session shortcut.
23
- Gallery detection reads the host's `product.json` through `vscode.workspace.fs`
24
- at `vscode.env.appRoot`; hosts without an inspectable gallery configuration hide
25
- the choice. Neither the extension nor its credentials are bundled or copied.
26
-
27
- The welcome's primary action uses `supercode.frontend.beginChatGPTSignIn`:
28
- it starts one retained install/sidebar operation and returns a copied receipt
29
- with its id, timestamps and current phase. This is a launch receipt, not proof
30
- that installation, sidebar presentation or authentication succeeded. A second
31
- begin while that operation is running returns the same id and starts no extra
32
- installation. `supercode.frontend.chatGPTSignInStatus` reads its current phase
33
- locally without waiting for host readiness or installation; pass the operation
34
- id to receive only that operation (a mismatched id returns null).
35
- Phases name the selected work before its awaited command: `checking-gallery`,
36
- `installing-extension`, `opening-sidebar`, then `completed` or `failed`.
37
- Completed means the official command sequence fulfilled, not signed in. Failures
38
- remain in operation status and the existing native error notification/output.
39
- Only the current or most recent operation is retained, for this extension-window
40
- lifetime; reload does not restore or automatically retry it. Runtime status
41
- inspection also includes this snapshot as `chatGPTSignIn`.
42
- The existing `supercode.frontend.signInWithChatGPT` still awaits the same work
43
- with its existing error-notification/fulfillment behavior, and the agent picker
44
- continues to use it. Ordinary relay budgets and draft guards are unchanged.
45
-
46
- The optional host-controls state can include `setup: { ready, actions, reason,
47
- cwd }`. Each action supplies `kind` (`login` or `install`), `harness`, `name` and
48
- the exact `command` revalidated before execution. On a signed-out first open,
49
- Chat shows its native empty-session welcome: one primary **Sign in with ChatGPT**
50
- button for the official extension, followed by **Supercode · local agents** and
51
- each installed agent's own sign-in link, labeled with its reported name. The
52
- `defaultChatParticipant` proposal supplies `additionalWelcomeMessage`; the pinned
53
- Code-OSS `ChatViewWelcomePart`, with the editor's native-welcome-button overlay,
54
- turns the standalone trusted command link into a native button. Stock hosts
55
- render it as an ordinary command link; product release requires the overlay.
56
- Setup creates no transcript turns, status sessions or protocol markers, and displays no terminal commands, filesystem paths or runtime banner.
57
- No installed agents means an explanatory empty state, without invented choices.
58
- Clicking asks the host's authenticated `/setup` door to revalidate `{ kind,
59
- harness }` and return the action plus a structured `{ program, arguments, cwd, env? }`
60
- terminal launch. Absolute executables and an optional host-authored PATH keep
61
- the terminal on the same installation and discovery path as the host's probe.
62
- It runs directly as the integrated terminal's process, without typing a shell
63
- command; credentials and
64
- browser interaction belong to the harness. Failed or cancelled processes can be
65
- retried, and closing a terminal never counts as successful authentication.
66
-
67
- While setup is not ready, Chat refreshes `/state` every three seconds and exposes
68
- **Check again**. When the host returns a ready runtime connection, it attaches
69
- and reveals that conversation without a reload. Polling only finishes after both
70
- attachment and the host's guarded readiness reveal command succeed for that same
71
- session and runtime URL. Readiness/connection work completes before awaiting the
72
- native reveal, which may call back into activation. The optional
73
- `revealReadySessionCommand` checks the observed native conversation throughout
74
- queued navigation; a newer draft or selection cancels that automatic reveal.
75
- Hosts without that capability leave the handoff incomplete and direct the person
76
- to open the conversation from history. A transient attach/reveal failure stays pending and is retried;
77
- **Check again** retries that handoff even when the host already reports ready.
78
- Changed inventory updates welcome metadata in place, without opening another
79
- session or moving focus. Ready connections clear the welcome and reveal the
80
- actual conversation. Hosts without these optional fields keep the existing
81
- runtime-attachment commands.
14
+ When no coding agent is ready, native Chat offers **Sign in with ChatGPT**
15
+ (Codex) and **Sign in with Claude** (Claude Code), with the subtitle
16
+ “Use the AI plan you already pay for.” Both use the coding agent in this Chat.
17
+ A click starts one install/sign-in operation for that agent. The host returns
18
+ its exact structured terminal launch through the authenticated `/setup` door;
19
+ a missing agent installs from its vendor package, then the same click continues
20
+ to login. The frontend never interprets repair prose as a command.
21
+
22
+ The host's `/state` supplies `setup: {ready, actions, agents, reason, cwd}`.
23
+ Each agent supplies `{harness, name, installed, signedIn, account}`; an unknown
24
+ account is `null`. Each action supplies `{kind, harness, name, command}` with
25
+ `kind` equal to `install` or `login`. The host revalidates the chosen action
26
+ before returning `{program, arguments, cwd, env?}`. Credentials remain with
27
+ the coding agent; browser sign-in requires the person's approval.
28
+
29
+ The versioned local `supercode.frontend.setupState` command returns
30
+ `{version:1, visible, title, subtitle, footnote, composerPlaceholder,
31
+ composerEnabled, rows, signedInNotice}`. Rows include both providers with their
32
+ installation/login state, `phase` (`idle`, `installing`, `signing-in`, `failed`,
33
+ `signed-in`), `buttonLabel`, `caption`, `busy`, and `operationId`. A renderer
34
+ uses `visible` and `composerEnabled`, rather than the mere presence of rows,
35
+ to decide whether the welcome or disabled composer belongs in the current
36
+ view. This local read starts no process and makes no host request.
37
+
38
+ `supercode.frontend.beginSetup(harness)` starts work and returns its receipt.
39
+ Duplicate clicks on a busy agent return the same operation. The other agent
40
+ remains usable. `supercode.frontend.cancelSetup(harness, operationId)` cancels
41
+ only the terminal that operation started; a stale operation id does nothing.
42
+ Cancel returns to idle, while process failure shows **Try again** and
43
+ **Sign-in didn't finish.** The full reason is written to the Chat output log.
44
+ A process finishing never proves authentication: the host's native inventory
45
+ must report a signed-in account. Once it does, readiness polling attaches
46
+ and reveals the conversation without requiring another click, including when
47
+ sign-in finishes in a separate terminal.
48
+
49
+ Welcome metadata changes in place on each phase change. Cyclotron's paired
50
+ native renderer uses it to show primary/secondary buttons, disabled spinners,
51
+ one caption/Cancel/error slot per provider, and **Sign in to start a chat**
52
+ in the disabled composer. Stock hosts retain native Markdown links. The
53
+ editor's renderer/build is a separate integration; the frontend package alone
54
+ does not claim those richer controls are installed in every host. A successful
55
+ explicit sign-in publishes **Signed in with ChatGPT** or **Signed in with
56
+ Claude** above the newly opened Chat.
57
+
58
+ Setup creates no transcript turns. The guarded readiness reveal preserves a
59
+ newer draft or selection; a welcome-owned empty draft can give way to the first
60
+ ready conversation. Hosts without the guarded reveal command report the
61
+ limitation. Passive inventory polling retries a transient handoff failure until
62
+ attachment and reveal both succeed for the same conversation.
82
63
 
83
64
  A bundling host can expose a private lifecycle endpoint through
84
65
  `SUPERCODE_FRONTEND_HOST_URL` and `SUPERCODE_FRONTEND_HOST_CREDENTIAL_FILE`.
@@ -89,17 +70,15 @@ and returns its new scoped connection. The host owns validation, process lifetim
89
70
  persistence, and failure handling. Prompts and approvals still use the generated frontend client.
90
71
  The contract is in `src/host-controls.ts`.
91
72
 
92
- With that host, **New Session** / **Chat: New Chat: Choose Agent** asks only
93
- which startable agent to use. It refreshes the host's available choices first;
94
- with one ready local agent and no pending native sign-in choices it opens
95
- immediately, even when the official ChatGPT choice is available. Otherwise the
96
- picker includes the official choice on supported hosts; with no choices it returns
97
- to setup. Picking an agent sends model
98
- and reasoning as empty values, leaving both at the harness's own defaults,
99
- even if the previous conversation used explicit settings. Each agent whose model
100
- and reasoning a chat can set (Claude Code, Codex) is also listed in the picker as **<agent>: model
101
- and reasoning…**, which goes on to the model and effort choices below. Cancelling the agent
102
- picker starts no backend conversation.
73
+ With that host, **New Session** / **Chat: New Chat: Choose Agent** lists all
74
+ coding agents, including Codex and Claude Code when missing or signed out.
75
+ Their rows say **Signed in with ChatGPT/Claude** or **Sign in with
76
+ ChatGPT/Claude…**. A sign-in row starts the same install/login operation as the
77
+ welcome. Selecting a ready agent leaves model and reasoning at its defaults;
78
+ the separate **<agent>: model and reasoning…** rows retain explicit settings.
79
+ The picker is titled **New chat**, with **Choose your coding agent** as its
80
+ placeholder. Cancelling it starts no backend conversation. Initial automatic
81
+ agent selection and last-used selection remain the host's responsibility.
103
82
 
104
83
  The native blank chat opens before the picker appears. First send waits for the
105
84
  choice and the host's new connection through Code-OSS's session-creation handler;
@@ -111,7 +90,7 @@ paths. A failed startup leaves the draft available to retry.
111
90
 
112
91
  The pending agent/model choice belongs to that blank chat's untitled resource
113
92
  and is retired when another conversation opens. Cancelling or opening sign-in
114
- does not choose an agent. Readiness polling and **Check again** still discover
93
+ does not choose an agent. Readiness polling continues to discover
115
94
  agents while a draft is open, updating the welcome and model catalogue without
116
95
  replacing its widget or input. First send refreshes readiness; after a native
117
96
  sign-in choice it uses that agent once ready, otherwise it keeps the draft with
@@ -414,6 +393,22 @@ are read only for backwards compatibility.
414
393
  Restored response cancellation interrupts the runtime and waits for acknowledgement.
415
394
  Session-list updates consistently prioritize pending input over busy status.
416
395
 
396
+ Reload restoration joins the runtime's canonical history to events after its
397
+ `history_cursor`. A separately loaded native transcript is a fallback when the
398
+ runtime has no user history; it never replaces the live event tail. The final
399
+ running response is replayed through `activeResponseCallback`, with each pending
400
+ request drawn once from the snapshot and later events followed by sequence.
401
+ An idle runtime with a pending request also keeps this callback open until the
402
+ request is resolved. Standalone requests use the explicit runtime-history marker
403
+ instead of reopening an earlier answer. Managed conversations save their native
404
+ identity before sending input, as well as when a turn ends.
405
+ Stop is observed during the save and checked before input, including a lease
406
+ renewal retry. Retained request/decision records with no originating replay
407
+ prompt appear in a separate runtime-history turn.
408
+ The [reload investigation](../../docs/evidence/chat-pending-approval-reload-2026-10-08.md)
409
+ separates the retained first-user failure from this source change; a live
410
+ candidate reload and approval click remain unmeasured.
411
+
417
412
  The pinned Code-OSS host needs a resource-scoping repair in its input-state change
418
413
  routing: stock code broadcasts changes to all input states of a provider. The
419
414
  Volter overlay guards this fix with a native regression test. The former legacy
@@ -1,25 +1,40 @@
1
- /** Native Chat presentation; the host supplies inventory and terminal actions. */
1
+ /** Native Chat setup. The host owns inventory, commands and provider credentials. */
2
2
  import * as vscode from 'vscode';
3
- import type { ChatSetupState } from './host-controls.js';
4
- export declare const CHATGPT_SIGN_IN = "supercode.frontend.signInWithChatGPT";
5
- export declare const CHATGPT_SIGN_IN_BEGIN = "supercode.frontend.beginChatGPTSignIn";
6
- export declare const CHATGPT_SIGN_IN_STATUS = "supercode.frontend.chatGPTSignInStatus";
7
- export type ChatGPTSignInWorkPhase = 'checking-gallery' | 'installing-extension' | 'opening-sidebar';
8
- export interface ChatGPTSignInOperation {
3
+ import type { ChatSetupAgent, HostState } from './host-controls.js';
4
+ export declare const SETUP_BEGIN = "supercode.frontend.beginSetup";
5
+ export declare const SETUP_CANCEL = "supercode.frontend.cancelSetup";
6
+ export declare const SETUP_STATE = "supercode.frontend.setupState";
7
+ export type ChatSetupPhase = 'idle' | 'installing' | 'signing-in' | 'failed' | 'signed-in';
8
+ export interface ChatSetupOperation {
9
9
  readonly id: string;
10
- readonly phase: ChatGPTSignInWorkPhase | 'completed' | 'failed';
10
+ readonly harness: string;
11
+ readonly phase: ChatSetupPhase;
11
12
  readonly startedAt: number;
12
13
  readonly updatedAt: number;
13
- readonly finishedAt?: number;
14
14
  }
15
- /** The install command also exists in gallery-less Code-OSS. Fail closed when
16
- * the host's product configuration cannot be inspected through the VS Code API. */
17
- export declare function chatGPTAvailable(): Promise<boolean>;
18
- /** Only called by an explicit click. The gallery and extension own installation
19
- * and authentication; there is no terminal or provider credential handling here. */
20
- export declare function signInWithChatGPT(report?: (phase: ChatGPTSignInWorkPhase) => void): Promise<void>;
21
- /** Empty-chat welcome content, rendered by Code-OSS's ChatViewWelcomePart.
22
- * Only the official extension gets the primary button; native local sign-in
23
- * actions remain ordinary links. Commands and host diagnostic paths stay out
24
- * of the welcome, and the host still revalidates every action on click. */
25
- export declare function setupWelcome(setup: ChatSetupState, chatGPT?: boolean): vscode.MarkdownString;
15
+ export interface ChatSetupProvider extends ChatSetupAgent {
16
+ provider: string;
17
+ phase: ChatSetupPhase;
18
+ buttonLabel: string;
19
+ caption: string;
20
+ busy: boolean;
21
+ operationId: string | null;
22
+ }
23
+ export interface ChatSetupPresentation {
24
+ version: 1;
25
+ visible: boolean;
26
+ title: string;
27
+ subtitle: string;
28
+ footnote: string;
29
+ composerPlaceholder: string;
30
+ composerEnabled: boolean;
31
+ rows: ChatSetupProvider[];
32
+ signedInNotice: string | null;
33
+ }
34
+ /** Older hosts can describe readiness through actions and startable choices.
35
+ * An absent account stays unknown; a launched process never establishes login. */
36
+ export declare function setupAgents(state: HostState): ChatSetupAgent[];
37
+ export declare function setupPresentation(state: HostState, operations: ReadonlyMap<string, ChatSetupOperation>, notice?: string): ChatSetupPresentation;
38
+ /** Stock hosts keep native Markdown. Cyclotron reads the same state through
39
+ * SETUP_STATE to render disabled buttons, progress and the composer in place. */
40
+ export declare function setupWelcome(presentation: ChatSetupPresentation): vscode.MarkdownString;
@@ -1,69 +1,65 @@
1
- /** Native Chat presentation; the host supplies inventory and terminal actions. */
1
+ /** Native Chat setup. The host owns inventory, commands and provider credentials. */
2
2
  import * as vscode from 'vscode';
3
- export const CHATGPT_SIGN_IN = 'supercode.frontend.signInWithChatGPT';
4
- export const CHATGPT_SIGN_IN_BEGIN = 'supercode.frontend.beginChatGPTSignIn';
5
- export const CHATGPT_SIGN_IN_STATUS = 'supercode.frontend.chatGPTSignInStatus';
6
- const CODEX_EXTENSION = 'openai.chatgpt';
7
- const INSTALL_EXTENSION = 'workbench.extensions.installExtension';
8
- /** The install command also exists in gallery-less Code-OSS. Fail closed when
9
- * the host's product configuration cannot be inspected through the VS Code API. */
10
- export async function chatGPTAvailable() {
11
- try {
12
- if (!vscode.env.appRoot || !(await vscode.commands.getCommands(true)).includes(INSTALL_EXTENSION))
13
- return false;
14
- const bytes = await vscode.workspace.fs.readFile(vscode.Uri.joinPath(vscode.Uri.file(vscode.env.appRoot), 'product.json'));
15
- const product = JSON.parse(Buffer.from(bytes).toString('utf8'));
16
- const serviceUrl = product?.extensionsGallery?.serviceUrl;
17
- return typeof serviceUrl === 'string' && /^https?:\/\//.test(serviceUrl);
18
- }
19
- catch {
20
- return false;
21
- }
3
+ export const SETUP_BEGIN = 'supercode.frontend.beginSetup';
4
+ export const SETUP_CANCEL = 'supercode.frontend.cancelSetup';
5
+ export const SETUP_STATE = 'supercode.frontend.setupState';
6
+ const PROVIDERS = [
7
+ { harness: 'codex', name: 'Codex', provider: 'ChatGPT', caption: 'Runs Codex · ChatGPT Plus, Pro or Business' },
8
+ { harness: 'claude-code', name: 'Claude Code', provider: 'Claude', caption: 'Runs Claude Code · Claude Pro or Max' },
9
+ ];
10
+ /** Older hosts can describe readiness through actions and startable choices.
11
+ * An absent account stays unknown; a launched process never establishes login. */
12
+ export function setupAgents(state) {
13
+ if (state.setup?.agents)
14
+ return state.setup.agents.map(agent => ({ ...agent }));
15
+ const ids = new Set([...PROVIDERS.map(p => p.harness), ...state.harnesses.map(h => h.id), ...(state.setup?.actions ?? []).map(a => a.harness)]);
16
+ return [...ids].map(harness => {
17
+ const choice = state.harnesses.find(h => h.id === harness);
18
+ const actions = state.setup?.actions.filter(a => a.harness === harness) ?? [];
19
+ return { harness, name: choice?.name ?? PROVIDERS.find(p => p.harness === harness)?.name ?? actions[0]?.name ?? harness,
20
+ installed: !actions.some(a => a.kind === 'install') && Boolean(choice || actions.some(a => a.kind === 'login')),
21
+ signedIn: choice?.autoStart === true && !actions.some(a => a.kind === 'login'), account: null };
22
+ });
22
23
  }
23
- /** Only called by an explicit click. The gallery and extension own installation
24
- * and authentication; there is no terminal or provider credential handling here. */
25
- export async function signInWithChatGPT(report) {
26
- report?.('checking-gallery');
27
- if (!await chatGPTAvailable())
28
- throw new Error('This host does not provide an extension gallery.');
29
- if (!vscode.extensions.getExtension(CODEX_EXTENSION)) {
30
- // enable makes the workbench await the running extension registry, not
31
- // just the installed inventory, before we dispatch the contributed command.
32
- report?.('installing-extension');
33
- await vscode.commands.executeCommand(INSTALL_EXTENSION, CODEX_EXTENSION, { enable: true });
34
- }
35
- // openai.chatgpt contributes this command, not a public sign-in command.
36
- // Revealing its sidebar presents its own Sign in with ChatGPT when signed out.
37
- report?.('opening-sidebar');
38
- await vscode.commands.executeCommand('chatgpt.openSidebar');
24
+ export function setupPresentation(state, operations, notice) {
25
+ const agents = setupAgents(state);
26
+ const providers = PROVIDERS.map(provider => {
27
+ const agent = agents.find(a => a.harness === provider.harness) ?? { harness: provider.harness, name: provider.name, installed: false, signedIn: false, account: null };
28
+ const operation = operations.get(provider.harness);
29
+ const phase = agent.signedIn ? 'signed-in' : operation?.phase ?? 'idle';
30
+ const busy = phase === 'installing' || phase === 'signing-in';
31
+ return { ...agent, provider: provider.provider, phase,
32
+ buttonLabel: phase === 'installing' ? `Installing ${provider.name}…` : phase === 'signing-in' ? 'Finish signing in in your browser…'
33
+ : phase === 'failed' ? 'Try again' : `Sign in with ${provider.provider}`,
34
+ caption: busy ? 'Cancel' : phase === 'failed' ? "Sign-in didn't finish." : provider.caption,
35
+ busy, operationId: operation?.id ?? null };
36
+ });
37
+ const visible = Boolean(state.setup && !state.setup.ready);
38
+ return { version: 1, visible, title: 'Chat', subtitle: 'Use the AI plan you already pay for.',
39
+ footnote: 'Runs on this computer with your own account.', composerPlaceholder: visible ? 'Sign in to start a chat' : 'Ask a question or describe a task',
40
+ composerEnabled: !visible, rows: providers, signedInNotice: notice ? `Signed in with ${notice}` : null };
39
41
  }
40
- /** Empty-chat welcome content, rendered by Code-OSS's ChatViewWelcomePart.
41
- * Only the official extension gets the primary button; native local sign-in
42
- * actions remain ordinary links. Commands and host diagnostic paths stay out
43
- * of the welcome, and the host still revalidates every action on click. */
44
- export function setupWelcome(setup, chatGPT = false) {
42
+ /** Stock hosts keep native Markdown. Cyclotron reads the same state through
43
+ * SETUP_STATE to render disabled buttons, progress and the composer in place. */
44
+ export function setupWelcome(presentation) {
45
45
  const welcome = new vscode.MarkdownString();
46
- welcome.isTrusted = { enabledCommands: [CHATGPT_SIGN_IN_BEGIN, 'supercode.frontend.setup', 'supercode.frontend.refreshSetup'] };
47
- if (chatGPT) {
48
- welcome.appendMarkdown(`[Sign in with ChatGPT](command:${CHATGPT_SIGN_IN_BEGIN})\n\n`);
49
- welcome.appendMarkdown('Use the official Codex extension.');
46
+ welcome.isTrusted = { enabledCommands: [SETUP_BEGIN, SETUP_CANCEL] };
47
+ welcome.appendMarkdown(`${presentation.subtitle}\n\n`);
48
+ for (const provider of presentation.rows) {
49
+ const args = encodeURIComponent(JSON.stringify([provider.harness]));
50
+ if (provider.busy)
51
+ welcome.appendText(provider.buttonLabel);
52
+ else
53
+ welcome.appendMarkdown(`[${provider.buttonLabel}](command:${SETUP_BEGIN}?${args})`);
50
54
  welcome.appendMarkdown('\n\n');
51
- }
52
- welcome.appendMarkdown('**Supercode · local agents**\n\n');
53
- const login = setup.actions.filter(action => action.kind === 'login');
54
- if (login.length) {
55
- for (const action of login) {
56
- const title = new vscode.MarkdownString().appendText(`Sign in with ${action.name}`).value;
57
- const args = encodeURIComponent(JSON.stringify([action.kind, action.harness]));
58
- welcome.appendMarkdown(`- [${title}](command:supercode.frontend.setup?${args})\n`);
55
+ if (provider.busy) {
56
+ const cancel = encodeURIComponent(JSON.stringify([provider.harness, provider.operationId]));
57
+ welcome.appendMarkdown(`[Cancel](command:${SETUP_CANCEL}?${cancel})`);
59
58
  }
60
- welcome.appendMarkdown('\n');
61
- welcome.appendMarkdown('Each agent opens its own sign-in.');
62
- }
63
- else {
64
- welcome.appendMarkdown('No local agents are ready. Install an agent on this machine to use it with Supercode.');
59
+ else
60
+ welcome.appendText(provider.caption);
61
+ welcome.appendMarkdown('\n\n');
65
62
  }
66
- // Inline so it remains a secondary link, not another primary button.
67
- welcome.appendMarkdown('\n\nAlready signed in? [Check again](command:supercode.frontend.refreshSetup).');
63
+ welcome.appendText(presentation.footnote);
68
64
  return welcome;
69
65
  }
package/dist/extension.js CHANGED
@@ -2,14 +2,15 @@ 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 { CHATGPT_SIGN_IN, CHATGPT_SIGN_IN_BEGIN, CHATGPT_SIGN_IN_STATUS, chatGPTAvailable, setupWelcome, signInWithChatGPT } from './chat-setup.js';
5
+ import { SETUP_BEGIN, SETUP_CANCEL, SETUP_STATE, setupAgents, setupPresentation, setupWelcome } from './chat-setup.js';
6
+ import { ChatSetupFlow } from './setup-flow.js';
6
7
  import * as vscode from 'vscode';
7
8
  import { createFrontendClient } from './client.js';
8
9
  import { readCredentialFile } from './credential.js';
9
10
  import { grantedProposals, PROPOSAL_EFFECTS, REQUESTED_PROPOSALS } from './proposals.js';
10
11
  import { observeActiveResponse, sessionStatus } from './native-session.js';
11
12
  import { RequestNotPendingError, VscodeFrontendSession } from './session.js';
12
- import { acceptedDecisions, opaqueText, projectEvent, projectPendingRequests, projectHistory, projectReplayTurns, resolveParts, resolutionSentence, typedAnswer, } from './view.js';
13
+ import { acceptedDecisions, opaqueText, projectEvent, projectPendingRequests, projectHistory, projectReplayTurns, HISTORY_MARKER_PROMPT, resolveParts, resolutionSentence, typedAnswer, } from './view.js';
13
14
  const SESSION_SCHEME = 'supercode';
14
15
  /** The harnesses whose model and reasoning effort a new chat can set (the host passes them to the CLI). */
15
16
  const CONFIGURABLE_HARNESSES = ['claude-code', 'codex'];
@@ -345,38 +346,6 @@ export async function activate(context) {
345
346
  }
346
347
  }
347
348
  const controls = await ChatHostControls.fromEnvironment();
348
- const offerChatGPT = await chatGPTAvailable();
349
- let signingInWithChatGPT;
350
- let chatGPTSignInOperation;
351
- const signInSnapshot = () => chatGPTSignInOperation ? { ...chatGPTSignInOperation } : null;
352
- const startChatGPTSignIn = () => {
353
- if (signingInWithChatGPT)
354
- return signingInWithChatGPT;
355
- const startedAt = Date.now();
356
- const operation = { id: randomUUID(), startedAt };
357
- const update = (phase) => {
358
- const updatedAt = Date.now();
359
- chatGPTSignInOperation = { ...operation, phase, updatedAt,
360
- ...(phase === 'completed' || phase === 'failed' ? { finishedAt: updatedAt } : {}) };
361
- };
362
- signingInWithChatGPT = signInWithChatGPT(phase => update(phase)).then(() => {
363
- // Fulfilled install/sidebar commands do not establish authentication.
364
- update('completed');
365
- }).catch(error => {
366
- update('failed');
367
- log.error(`Could not open ChatGPT sign-in: ${String(error)}`);
368
- void vscode.window.showErrorMessage(`Could not open ChatGPT sign-in: ${error instanceof Error ? error.message : String(error)}`);
369
- }).finally(() => { signingInWithChatGPT = undefined; });
370
- return signingInWithChatGPT;
371
- };
372
- context.subscriptions.push(
373
- // Preserve the completion-awaiting command used by the agent picker.
374
- vscode.commands.registerCommand(CHATGPT_SIGN_IN, startChatGPTSignIn),
375
- // A click starts one retained task. Its receipt is not install/login success.
376
- vscode.commands.registerCommand(CHATGPT_SIGN_IN_BEGIN, () => {
377
- void startChatGPTSignIn();
378
- return signInSnapshot();
379
- }), vscode.commands.registerCommand(CHATGPT_SIGN_IN_STATUS, (operationId) => operationId === undefined || operationId === chatGPTSignInOperation?.id ? signInSnapshot() : null));
380
349
  const controlsChanged = new vscode.EventEmitter();
381
350
  context.subscriptions.push(controlsChanged);
382
351
  // A person's explicit picker choice: per conversation once it is bound, and for the new chat before then.
@@ -628,6 +597,7 @@ export async function activate(context) {
628
597
  setupHandoff = { id, baseUrl: connection.baseUrl };
629
598
  };
630
599
  let setupRevealed = false;
600
+ let welcomeWasVisible = controls?.state.setup?.ready === false;
631
601
  const revealSetup = async () => {
632
602
  if (!granted.has('chatSessionsProvider') || !controls?.state.setup || controls.state.setup.ready || setupRevealed)
633
603
  return;
@@ -636,6 +606,13 @@ export async function activate(context) {
636
606
  await vscode.commands.executeCommand(`workbench.action.chat.openNewChatSessionInPlace.${SESSION_SCHEME}`, 'sidebar');
637
607
  setupRevealed = true;
638
608
  };
609
+ let refreshingInventory;
610
+ const refreshInventory = () => {
611
+ if (!controls)
612
+ return Promise.resolve();
613
+ refreshingInventory ??= controls.refresh().then(() => { controlsChanged.fire(); }).finally(() => { refreshingInventory = undefined; });
614
+ return refreshingInventory;
615
+ };
639
616
  let refreshingSetup;
640
617
  const refreshSetup = () => {
641
618
  if (refreshingSetup)
@@ -645,9 +622,10 @@ export async function activate(context) {
645
622
  return;
646
623
  const generation = focusGeneration;
647
624
  const focusResource = focusedResource?.toString() ?? null;
625
+ welcomeWasVisible ||= controls.state.setup?.ready === false;
648
626
  // Discover and publish readiness even while a blank chat holds a draft.
649
627
  // Only the automatic reveal is suppressed; discovery must never stall.
650
- await controls.refresh();
628
+ await refreshInventory();
651
629
  const state = controls.state;
652
630
  controlsChanged.fire();
653
631
  if (state.setup?.ready || (!state.setup && state.connection?.SUPERCODE_FRONTEND_URL)) {
@@ -672,7 +650,7 @@ export async function activate(context) {
672
650
  const live = target?.kind === 'managed' && Boolean(frontends.get(target.id)?.session.pendingRequests.size
673
651
  || frontends.get(target.id)?.session.descriptor?.turn_state === 'busy');
674
652
  if (!target || !controls || applying !== applied || target.generation !== focusGeneration
675
- || (focusedResource?.path.startsWith('/untitled-') && !live))
653
+ || (focusedResource?.path.startsWith('/untitled-') && !live && !welcomeWasVisible))
676
654
  return;
677
655
  if (target.kind === 'managed') {
678
656
  if ((controls.state.setup && !controls.state.setup.ready) || controls.state.activeSession !== target.id
@@ -680,45 +658,74 @@ export async function activate(context) {
680
658
  return;
681
659
  if (!setupHandoffComplete())
682
660
  await revealManaged(target, { resource: target.focusResource });
661
+ if (setupHandoffComplete())
662
+ welcomeWasVisible = false;
683
663
  }
684
664
  else if (!controls.state.setup?.ready) {
685
665
  await revealSetup();
686
666
  }
687
667
  });
688
- // Coalesce Check again/polling through actual reveal completion or refusal.
668
+ // Coalesce inventory refresh through actual reveal completion or refusal.
689
669
  refreshingSetup = revealed.finally(() => { refreshingSetup = undefined; });
690
670
  return refreshingSetup;
691
671
  };
692
672
  const setupTerminals = new Map();
693
673
  context.subscriptions.push(vscode.window.onDidCloseTerminal(terminal => {
694
- for (const [key, value] of setupTerminals)
695
- if (value === terminal)
696
- setupTerminals.delete(key);
697
- }));
698
- context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.refreshSetup', refreshSetup));
699
- context.subscriptions.push(vscode.commands.registerCommand('supercode.frontend.setup', async (kind, harness) => {
700
- if (!controls)
701
- throw new Error('This host does not expose sign-in.');
702
- const key = `${kind}/${harness}`;
703
- const existing = setupTerminals.get(key);
704
- if (existing && existing.exitStatus === undefined) {
705
- existing.show();
706
- return;
674
+ const resolve = setupTerminals.get(terminal);
675
+ if (resolve) {
676
+ setupTerminals.delete(terminal);
677
+ resolve(terminal.exitStatus?.code);
707
678
  }
708
- await controls.refresh();
709
- const offered = controls.state.setup?.actions.find(a => a.kind === kind && a.harness === harness);
710
- if (!offered) {
711
- await refreshSetup();
712
- throw new Error('This setup action is no longer available.');
713
- }
714
- const action = await controls.setup(kind, harness);
715
- if (action.command !== offered.command)
716
- throw new Error('The setup command changed. Check again before running it.');
717
- const terminal = vscode.window.createTerminal({ name: `${action.name} · ${kind === 'login' ? 'sign in' : 'install'}`,
718
- cwd: action.cwd, shellPath: action.program, shellArgs: action.arguments, env: action.env });
719
- setupTerminals.set(key, terminal);
720
- terminal.show();
721
679
  }));
680
+ const setupFlow = controls ? new ChatSetupFlow({
681
+ state: () => controls.state,
682
+ refresh: async () => {
683
+ await refreshInventory();
684
+ // Inventory settles independently of guarded navigation: a person moving
685
+ // focus does not turn a successful native login into a failed one.
686
+ if (controls.state.setup?.ready && !setupHandoffComplete()) {
687
+ void refreshSetup().catch(error => log.warn(`Could not reveal signed-in Chat: ${String(error)}`));
688
+ }
689
+ },
690
+ changed: () => {
691
+ const choice = activeDraft()?.choice;
692
+ if (choice?.kind === 'signIn' && choice.harness && setupAgents(controls.state).some(agent => agent.harness === choice.harness && agent.signedIn)) {
693
+ activeDraft().choice = { kind: 'agent', selection: { harness: choice.harness, model: '', effort: '' } };
694
+ }
695
+ controlsChanged.fire();
696
+ },
697
+ log: message => log.error(message),
698
+ launch: async (kind, harness, current) => {
699
+ if (!current())
700
+ throw new vscode.CancellationError();
701
+ const offered = controls.state.setup?.actions.find(a => a.kind === kind && a.harness === harness);
702
+ if (!offered)
703
+ throw new Error('This sign-in or install action is unavailable.');
704
+ const action = await controls.setup(kind, harness);
705
+ if (!current())
706
+ throw new vscode.CancellationError();
707
+ if (action.command !== offered.command)
708
+ throw new Error('The sign-in command changed. Try again.');
709
+ const terminal = vscode.window.createTerminal({ name: `${action.name} · ${kind === 'login' ? 'sign in' : 'install'}`,
710
+ cwd: action.cwd, shellPath: action.program, shellArgs: action.arguments, env: action.env });
711
+ const exited = new Promise(resolve => {
712
+ setupTerminals.set(terminal, resolve);
713
+ // The process can finish before the close listener has observed this terminal.
714
+ if (terminal.exitStatus) {
715
+ setupTerminals.delete(terminal);
716
+ resolve(terminal.exitStatus.code);
717
+ }
718
+ });
719
+ terminal.show();
720
+ return { exited, cancel: () => terminal.dispose() };
721
+ },
722
+ }) : undefined;
723
+ const setupState = () => controls ? setupPresentation(controls.state, setupFlow?.operations ?? new Map(), setupFlow?.notice) : null;
724
+ context.subscriptions.push({ dispose: () => setupFlow?.dispose() }, vscode.commands.registerCommand('supercode.frontend.refreshSetup', refreshSetup), vscode.commands.registerCommand(SETUP_STATE, setupState), vscode.commands.registerCommand(SETUP_BEGIN, (harness) => {
725
+ if (!setupFlow)
726
+ throw new Error('This host does not expose sign-in.');
727
+ return setupFlow.begin(harness);
728
+ }), vscode.commands.registerCommand(SETUP_CANCEL, (harness, operationId) => setupFlow?.cancel(harness, operationId) ?? false));
722
729
  const restoring = new Map();
723
730
  const connectionFor = async (resource, activate = true, token) => {
724
731
  if (savedAttachments.has(resource.authority)) {
@@ -943,7 +950,7 @@ export async function activate(context) {
943
950
  throw new Error(`Finish signing in with ${harnessDisplayName(loginHarness)} before sending.`);
944
951
  }
945
952
  if (choice?.kind !== 'agent' && controls.state.setup && !controls.state.setup.ready) {
946
- throw new Error('Sign in before sending; use Check again after signing in.');
953
+ throw new Error('Sign in to start a chat.');
947
954
  }
948
955
  const selection = choice?.kind === 'agent' ? choice.selection
949
956
  : { harness: loginHarness ?? controls.state.selection.harness, model: '', effort: '' };
@@ -1078,41 +1085,31 @@ export async function activate(context) {
1078
1085
  return;
1079
1086
  }
1080
1087
  await controls.refresh();
1081
- // Sign-in choices stay visible alongside agents that can be explicitly started.
1082
- const login = controls.state.setup?.actions.filter(action => action.kind === 'login') ?? [];
1083
- const localChoices = [
1084
- ...login.map(action => ({ label: action.name, id: action.harness, description: 'Sign in…', login: true, autoStart: false, configure: false })),
1085
- ...controls.state.harnesses.filter(h => !login.some(action => action.harness === h.id))
1086
- .map(h => ({ label: h.name, id: h.id, description: h.description, login: false, autoStart: h.autoStart === true, configure: false })),
1087
- ];
1088
- // Each agent whose model and reasoning a chat can set also offers that, right where the agent is chosen.
1088
+ const agents = setupAgents(controls.state);
1089
+ const localChoices = agents.map(agent => ({ label: agent.name, id: agent.harness,
1090
+ description: agent.harness === 'codex' ? (agent.signedIn ? 'Signed in with ChatGPT' : 'Sign in with ChatGPT…')
1091
+ : agent.harness === 'claude-code' ? (agent.signedIn ? 'Signed in with Claude' : 'Sign in with Claude…')
1092
+ : controls.state.harnesses.find(h => h.id === agent.harness)?.description,
1093
+ login: controls.state.setup?.actions.some(a => a.harness === agent.harness) ?? false,
1094
+ autoStart: controls.state.harnesses.some(h => h.id === agent.harness && h.autoStart), configure: false }));
1089
1095
  const modelChoices = localChoices.filter(choice => !choice.login && CONFIGURABLE_HARNESSES.includes(choice.id))
1090
- .map(choice => ({ ...choice, label: `${choice.label}: model and reasoning…`, description: 'Choose the model and reasoning effort', autoStart: false, configure: true }));
1091
- const choices = [
1092
- ...(offerChatGPT ? [{ label: 'Sign in with ChatGPT', id: CHATGPT_SIGN_IN, description: 'Official Codex extension', login: false, autoStart: false, configure: false }] : []),
1093
- ...localChoices,
1094
- ...modelChoices,
1095
- ];
1096
+ .map(choice => ({ ...choice, label: `${choice.label}: model and reasoning…`, description: undefined, autoStart: false, configure: true }));
1097
+ const choices = [...localChoices, ...(modelChoices.length ? [{ label: '', id: '__models__', kind: vscode.QuickPickItemKind.Separator,
1098
+ description: undefined, login: false, autoStart: false, configure: false }, ...modelChoices] : [])];
1096
1099
  if (!choices.length) {
1097
1100
  await revealSetup();
1098
1101
  return;
1099
1102
  }
1100
- // A sole sign-in option still requires a deliberate click before opening its terminal.
1101
- // The official choice must not disable the existing sole-local-agent shortcut.
1102
1103
  await chooseNewChat(async () => {
1103
- const harness = localChoices.length === 1 && localChoices[0].autoStart ? localChoices[0] : await vscode.window.showQuickPick(choices, { title: 'New chat · Agent', placeHolder: 'Choose your coding agent. Its default model and reasoning apply unless you choose them.' });
1104
+ const harness = await vscode.window.showQuickPick(choices, { title: 'New chat', placeHolder: 'Choose your coding agent' });
1104
1105
  if (!harness)
1105
1106
  return { kind: 'cancelled' };
1106
1107
  if (harness.configure) {
1107
1108
  const chosen = await pickModelAndEffort(harness.id);
1108
1109
  return chosen ? { kind: 'agent', selection: { harness: harness.id, ...chosen } } : { kind: 'cancelled' };
1109
1110
  }
1110
- if (harness.id === CHATGPT_SIGN_IN) {
1111
- await vscode.commands.executeCommand(CHATGPT_SIGN_IN);
1112
- return { kind: 'signIn' };
1113
- }
1114
1111
  if (harness.login) {
1115
- await vscode.commands.executeCommand('supercode.frontend.setup', 'login', harness.id);
1112
+ await vscode.commands.executeCommand(SETUP_BEGIN, harness.id);
1116
1113
  return { kind: 'signIn', harness: harness.id };
1117
1114
  }
1118
1115
  return { kind: 'agent', selection: { harness: harness.id, model: '', effort: '' } };
@@ -1298,6 +1295,11 @@ export async function activate(context) {
1298
1295
  } };
1299
1296
  };
1300
1297
  const runTurnInto = async (connection, prompt, stream, token, approval) => {
1298
+ const throwIfCancelled = () => {
1299
+ if (token.isCancellationRequested)
1300
+ throw new vscode.CancellationError();
1301
+ };
1302
+ throwIfCancelled();
1301
1303
  if (approval() === 'autoApprove' && !canAnswer(connection)) {
1302
1304
  throw new Error('This harness does not support automatic approval through Chat. Select Harness permissions.');
1303
1305
  }
@@ -1338,13 +1340,20 @@ export async function activate(context) {
1338
1340
  const unwatchTasks = connection.session.onChange(sayTasks);
1339
1341
  let completed = false;
1340
1342
  try {
1343
+ // Observe Stop throughout the save, then check the token before input.
1344
+ // A cancellation while this idle runtime saves cannot be handled by an
1345
+ // interrupt alone: there is no running turn to interrupt yet.
1346
+ if (controls && !attachedIds.has(connection.id) && connection.session.descriptor?.session_id) {
1347
+ await controls.remember(connection.id, connection.session.descriptor.session_id);
1348
+ }
1349
+ throwIfCancelled();
1341
1350
  await connection.session.runTurn(prompt, (event) => {
1342
1351
  // A turn the person stopped is marked stopped by VS Code itself; only an interruption from elsewhere is said.
1343
1352
  if (event.kind === 'turn_interrupted' && token.isCancellationRequested)
1344
1353
  return;
1345
1354
  writeProjected(stream, projectEvent(event, projection), granted, state);
1346
1355
  watch.sweep();
1347
- });
1356
+ }, { throwIfCancelled });
1348
1357
  completed = true;
1349
1358
  }
1350
1359
  finally {
@@ -1447,14 +1456,18 @@ export async function activate(context) {
1447
1456
  if (!granted.has('defaultChatParticipant'))
1448
1457
  return;
1449
1458
  const setup = controls?.state.setup;
1450
- const welcome = setup && !setup.ready ? setupWelcome(setup, offerChatGPT)
1459
+ const presentation = setupState();
1460
+ const welcome = setup && !setup.ready ? setupWelcome(presentation)
1451
1461
  : !controls && frontends.all.length === 0 ? new vscode.MarkdownString(unavailableMarkdown(frontends.unavailable))
1452
1462
  : undefined;
1453
- if (welcomeValue === welcome?.value)
1463
+ const key = JSON.stringify([welcome?.value, presentation]);
1464
+ if (welcomeValue === key)
1454
1465
  return;
1455
- welcomeValue = welcome?.value;
1466
+ welcomeValue = key;
1456
1467
  // Metadata updates merge across RPC; undefined can leave the previous
1457
1468
  // sign-in message behind. An explicit empty value clears it in place.
1469
+ // The native renderer owns the signed-in notice above a ready conversation.
1470
+ // Keeping it in this fallback message would draw it again below that notice.
1458
1471
  for (const participant of participants)
1459
1472
  participant.additionalWelcomeMessage = welcome ?? new vscode.MarkdownString('');
1460
1473
  };
@@ -1615,7 +1628,7 @@ export async function activate(context) {
1615
1628
  await controls.refresh();
1616
1629
  if (inspect === true)
1617
1630
  return {
1618
- chatGPTSignIn: signInSnapshot(),
1631
+ setupState: setupState(),
1619
1632
  connections: frontends.all.map(c => ({ ...frontends.info(c), permissions: [...c.session.permissions],
1620
1633
  pendingRequests: [...c.session.pendingRequests.values()] })),
1621
1634
  selection: controls?.state.selection, activeSession: controls?.state.activeSession,
@@ -1779,7 +1792,10 @@ export async function activate(context) {
1779
1792
  let setupDisposed = false;
1780
1793
  const pollSetup = () => {
1781
1794
  const choice = activeDraft()?.choice;
1782
- const awaitingLogin = choice?.kind === 'signIn' && choice.harness && !controls?.state.harnesses.some(h => h.id === choice.harness && h.autoStart);
1795
+ const awaitingLogin = (choice?.kind === 'signIn' && choice.harness && !setupAgents(controls.state).some(h => h.harness === choice.harness && h.signedIn))
1796
+ || [...(setupFlow?.operations.values() ?? [])].some(operation => operation.phase === 'installing' || operation.phase === 'signing-in');
1797
+ if ([...(setupFlow?.operations.values() ?? [])].some(operation => operation.phase === 'installing' || operation.phase === 'signing-in'))
1798
+ return;
1783
1799
  if (setupTimer || setupDisposed || !controls?.state.setup || (controls.state.setup.ready && setupHandoffComplete() && !awaitingLogin))
1784
1800
  return;
1785
1801
  setupTimer = setTimeout(() => {
@@ -1788,6 +1804,7 @@ export async function activate(context) {
1788
1804
  }, 3000);
1789
1805
  };
1790
1806
  pollSetup();
1807
+ context.subscriptions.push(controlsChanged.event(pollSetup));
1791
1808
  context.subscriptions.push({ dispose: () => { setupDisposed = true; clearTimeout(setupTimer); } });
1792
1809
  return {
1793
1810
  connect: async (options) => {
@@ -2021,12 +2038,16 @@ function registerSessions(context, frontends, participant, granted, handler, his
2021
2038
  // canonical transcript, replayed as turns.
2022
2039
  const history = [];
2023
2040
  const authorId = historyParticipant(connection);
2024
- // A hosted harness runtime has no canonical ChatMessage history —
2025
- // `HostedHarnessRuntime::attach` returns an empty one and puts the
2026
- // conversation in `replay`. Read whichever the runtime actually
2027
- // filled, or a resumed session opens blank beside the person's own
2028
- // conversation.
2029
- const canonicalHistory = connection.persistedHistory?.length ? connection.persistedHistory : connection.session.history;
2041
+ // History and replay are one runtime snapshot, joined by its cursor.
2042
+ // A host's separately loaded native transcript has no replay cursor;
2043
+ // selecting it instead hid every turn started since the runtime was
2044
+ // created. Keep it only as a fallback when no runtime user history is
2045
+ // available, rather than trying to align equal-looking prompt text.
2046
+ const runtimeHistory = connection.session.history;
2047
+ const replay = connection.session.replay.filter(event => event.sequence > connection.session.historyCursor);
2048
+ const hasRuntimeHistory = runtimeHistory.length > 0 || replay.some(event => event.kind === 'user_message');
2049
+ const usingSavedHistory = !hasRuntimeHistory && Boolean(connection.persistedHistory?.length);
2050
+ const canonicalHistory = usingSavedHistory ? connection.persistedHistory : runtimeHistory;
2030
2051
  const pendingToolNames = new Set();
2031
2052
  if (connection.session.descriptor?.turn_state === 'busy') {
2032
2053
  for (const request of connection.session.pendingRequests.values()) {
@@ -2038,14 +2059,44 @@ function registerSessions(context, frontends, participant, granted, handler, his
2038
2059
  pendingToolNames.add(name);
2039
2060
  }
2040
2061
  }
2041
- const projected = canonicalHistory.length > 0
2042
- ? projectHistory(canonicalHistory, granted, pendingToolNames)
2043
- : projectReplayTurns(connection.session.replay, granted, {
2044
- connectionId: connection.id,
2045
- pendingRequest: (id) => connection.session.pendingRequests.get(id) ?? connection.session.resolvedRequests.get(id),
2046
- canRespond: connection.session.descriptor?.actions.respond !== false,
2047
- });
2048
- if (connection.historyTruncated)
2062
+ const projected = projectReplayTurns(replay.filter(event => {
2063
+ if (event.kind !== 'request')
2064
+ return true;
2065
+ const payload = event.payload;
2066
+ return !payload?.request || !connection.session.pendingRequests.has(payload.request.id);
2067
+ }), granted, {
2068
+ connectionId: connection.id,
2069
+ pendingRequest: (id) => connection.session.pendingRequests.get(id) ?? connection.session.resolvedRequests.get(id),
2070
+ canRespond: Boolean(connection.session.descriptor?.actions.respond && connection.session.permissions.has('approve')),
2071
+ }, projectHistory(canonicalHistory, granted, pendingToolNames));
2072
+ const live = connection.session.descriptor?.turn_state === 'busy' || connection.session.pendingRequests.size > 0;
2073
+ const activeParts = [];
2074
+ if (live) {
2075
+ // Standalone requests have no user turn. Give their live response
2076
+ // an explicit runtime marker instead of reopening an old answer.
2077
+ let lastBoundary = -1;
2078
+ let lastUser = -1;
2079
+ for (const event of replay) {
2080
+ if (['turn_succeeded', 'turn_failed', 'turn_interrupted'].includes(event.kind))
2081
+ lastBoundary = event.sequence;
2082
+ if (event.kind === 'user_message')
2083
+ lastUser = event.sequence;
2084
+ }
2085
+ if (!projected.some(turn => turn.role === 'user')
2086
+ || (connection.session.descriptor?.turn_state !== 'busy' && lastUser <= lastBoundary)) {
2087
+ projected.push({ role: 'user', prompt: HISTORY_MARKER_PROMPT });
2088
+ }
2089
+ // Leave the final request without a historical response, so the
2090
+ // callback creates its response from live progress. Rehydrated
2091
+ // responses can be stale; their command buttons are disabled.
2092
+ const last = projected.at(-1);
2093
+ if (last?.role === 'assistant') {
2094
+ activeParts.push(...(projected.pop()?.parts ?? []).map(part => part.part === 'answers'
2095
+ ? { part: 'markdown', value: `**${part.title}**\n\n${part.message}` }
2096
+ : part));
2097
+ }
2098
+ }
2099
+ if (usingSavedHistory && connection.historyTruncated)
2049
2100
  history.push(new turns.ChatRequestTurn('[Earlier conversation history]', undefined, [], authorId, []), new turns.ChatResponseTurn([new vscode.ChatResponseMarkdownPart('_Showing the latest 120 saved messages. Earlier context remains in the harness session._')], {}, authorId));
2050
2101
  for (const turn of projected) {
2051
2102
  if (turn.role === 'user') {
@@ -2054,16 +2105,16 @@ function registerSessions(context, frontends, participant, granted, handler, his
2054
2105
  }
2055
2106
  history.push(new turns.ChatResponseTurn(historyParts(oneRowPerToolCall(turn.parts ?? [])), {}, authorId));
2056
2107
  }
2057
- const busy = connection.session.descriptor?.turn_state === 'busy';
2058
2108
  const renderedCursor = connection.session.cursor;
2059
- if (!busy)
2109
+ const pendingAtCursor = [...connection.session.pendingRequests.values()];
2110
+ if (!live)
2060
2111
  connection.viewReady = attached;
2061
2112
  return {
2062
2113
  title: connection.label,
2063
2114
  options: controls?.attached(connection) ? undefined : controls?.currentOptions(resource),
2064
2115
  history,
2065
2116
  requestHandler: boundHandler,
2066
- activeResponseCallback: busy
2117
+ activeResponseCallback: live
2067
2118
  ? async (stream, token) => {
2068
2119
  // A turn re-joined after a reload has lost the handler that ran it, so its approvals are
2069
2120
  // answered here under the conversation's mode (t_39dac582): Auto approve answers the cards
@@ -2071,22 +2122,24 @@ function registerSessions(context, frontends, participant, granted, handler, his
2071
2122
  const watch = controls && !controls.attached(connection) ? controls.watchApprovals(connection, stream, token) : undefined;
2072
2123
  try {
2073
2124
  const state = { toolInputs: new Map(), toolNames: new Map(), toolTitles: new Map(), toolCommands: new Map(), quiet: watch?.answers };
2074
- writeProjected(stream, projectPendingRequests(connection.session.pendingRequests.values(), {
2125
+ for (const part of oneRowPerToolCall(activeParts))
2126
+ writePart(stream, part, state);
2127
+ writeProjected(stream, projectPendingRequests(pendingAtCursor.filter(request => connection.session.pendingRequests.has(request.id)), {
2075
2128
  connectionId: connection.id,
2076
- canRespond: connection.session.descriptor?.actions.respond !== false,
2129
+ canRespond: Boolean(connection.session.descriptor?.actions.respond && connection.session.permissions.has('approve')),
2077
2130
  }), granted, state);
2078
2131
  watch?.sweep();
2079
2132
  await observeActiveResponse(connection.session, token, (event) => {
2080
2133
  writeProjected(stream, projectEvent(event, {
2081
2134
  connectionId: connection.id,
2082
2135
  pendingRequest: (id) => connection.session.pendingRequests.get(id) ?? connection.session.resolvedRequests.get(id),
2083
- canRespond: connection.session.descriptor?.actions.respond !== false,
2136
+ canRespond: Boolean(connection.session.descriptor?.actions.respond && connection.session.permissions.has('approve')),
2084
2137
  }), granted, state);
2085
2138
  watch?.sweep();
2086
2139
  }, () => { connection.viewReady = true; }, renderedCursor);
2087
2140
  }
2088
2141
  finally {
2089
- connection.viewReady = connection.session.descriptor?.turn_state !== 'busy';
2142
+ connection.viewReady = connection.session.descriptor?.turn_state !== 'busy' && !connection.session.pendingRequests.size;
2090
2143
  watch?.dispose();
2091
2144
  }
2092
2145
  }
@@ -35,9 +35,17 @@ export interface ChatSetupAction {
35
35
  name: string;
36
36
  command: string;
37
37
  }
38
+ export interface ChatSetupAgent {
39
+ harness: string;
40
+ name: string;
41
+ installed: boolean;
42
+ signedIn: boolean;
43
+ account: string | null;
44
+ }
38
45
  export interface ChatSetupState {
39
46
  ready: boolean;
40
47
  actions: ChatSetupAction[];
48
+ agents?: ChatSetupAgent[];
41
49
  reason: string | null;
42
50
  cwd: string;
43
51
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { createFrontendClient, type FrontendPermission, type VscodeFrontendConnection } from './client.js';
2
2
  export { readCredentialFile } from './credential.js';
3
- export type { ChatGPTSignInWorkPhase, ChatGPTSignInOperation } from './chat-setup.js';
3
+ export type { ChatSetupPhase, ChatSetupOperation, ChatSetupProvider, ChatSetupPresentation } from './chat-setup.js';
4
+ export type { ChatSetupAgent } from './host-controls.js';
4
5
  export { grantedProposals, PROPOSAL_EFFECTS, REQUESTED_PROPOSALS, type ProposalName, } from './proposals.js';
5
6
  export { EventSequenceGapError, LEASE_ERROR_NAMES, RequestNotPendingError, TURN_TERMINAL_KINDS, VscodeFrontendSession, type VscodeFrontendActions, type VscodeRuntimeClient, } from './session.js';
6
7
  export { HISTORY_MARKER_PROMPT, VSCODE_CHAT_LIMITS, acceptedDecisions, diffPaths, fence, opaqueText, projectEvent, projectPendingRequests, projectHistory, projectReplayTurns, requestAnswers, requestNeedsTypedReply, requestSubject, requestSummary, resolutionSentence, resolveParts, toolSubject, typedAnswer, type ChatAnswer, type ChatPart, type ProjectedPart, type ProjectedTurn, type ProjectionContext, } from './view.js';
@@ -3,4 +3,4 @@ import type { FrontendEvent } from '../vendor/supercode-frontend/generated.mjs';
3
3
  import { type VscodeFrontendSession } from './session.js';
4
4
  export declare function sessionStatus(session: Pick<VscodeFrontendSession, 'descriptor' | 'pendingRequests'>): 'NeedsInput' | 'InProgress' | 'Completed';
5
5
  /** Follow a restored turn, including the interval between snapshot and callback. */
6
- export declare function observeActiveResponse(session: Pick<VscodeFrontendSession, 'descriptor' | 'onEvent' | 'interrupt' | 'actions' | 'replay'>, token: CancellationToken, write: (event: FrontendEvent) => void, onSubscribed?: () => void, afterSequence?: number): Promise<void>;
6
+ export declare function observeActiveResponse(session: Pick<VscodeFrontendSession, 'descriptor' | 'pendingRequests' | 'onEvent' | 'interrupt' | 'actions' | 'replay'>, token: CancellationToken, write: (event: FrontendEvent) => void, onSubscribed?: () => void, afterSequence?: number): Promise<void>;
@@ -34,7 +34,12 @@ export async function observeActiveResponse(session, token, write, onSubscribed,
34
34
  reject(error);
35
35
  return;
36
36
  }
37
- if (!cancelling && TURN_TERMINAL_KINDS.includes(event.kind))
37
+ if (event.kind === 'runtime_disconnected') {
38
+ resolve();
39
+ return;
40
+ }
41
+ const ended = TURN_TERMINAL_KINDS.includes(event.kind) || event.kind === 'request_resolved';
42
+ if (!cancelling && ended && session.descriptor?.turn_state !== 'busy' && !session.pendingRequests.size)
38
43
  resolve();
39
44
  };
40
45
  unsubscribe = session.onEvent(consume);
@@ -47,7 +52,7 @@ export async function observeActiveResponse(session, token, write, onSubscribed,
47
52
  cancellation = token.onCancellationRequested(cancel);
48
53
  if (token.isCancellationRequested)
49
54
  cancel();
50
- else if (session.descriptor?.turn_state !== 'busy')
55
+ else if (session.descriptor?.turn_state !== 'busy' && !session.pendingRequests.size)
51
56
  resolve();
52
57
  });
53
58
  }
package/dist/session.d.ts CHANGED
@@ -147,7 +147,7 @@ export declare class VscodeFrontendSession {
147
147
  actions(): VscodeFrontendActions;
148
148
  /** Run one controller action, re-taking an expired lease exactly once. */
149
149
  private withControl;
150
- submit(prompt: string): Promise<void>;
150
+ submit(prompt: string, throwIfCancelled?: () => void): Promise<void>;
151
151
  steer(prompt: string): Promise<void>;
152
152
  interrupt(): Promise<void>;
153
153
  /** Stop one of the harness's own background tasks, by the id it listed it with. */
@@ -164,6 +164,7 @@ export declare class VscodeFrontendSession {
164
164
  */
165
165
  runTurn(prompt: string, onEvent: (event: FrontendEvent) => void, options?: {
166
166
  onCancel?: (cancel: () => void) => void;
167
+ throwIfCancelled?: () => void;
167
168
  }): Promise<FrontendEvent | null>;
168
169
  private require;
169
170
  private notify;
package/dist/session.js CHANGED
@@ -356,11 +356,16 @@ export class VscodeFrontendSession {
356
356
  return action();
357
357
  }
358
358
  }
359
- async submit(prompt) {
359
+ async submit(prompt, throwIfCancelled) {
360
+ throwIfCancelled?.();
360
361
  this.require('submit');
361
362
  if (prompt.length === 0)
362
363
  throw new TypeError('prompt must not be empty');
363
- await this.withControl(() => this.client.sendInput(prompt));
364
+ await this.withControl(() => {
365
+ // The same guard covers a send retried after an awaited lease renewal.
366
+ throwIfCancelled?.();
367
+ return this.client.sendInput(prompt);
368
+ });
364
369
  }
365
370
  async steer(prompt) {
366
371
  this.require('steer');
@@ -474,7 +479,7 @@ export class VscodeFrontendSession {
474
479
  void this.interrupt().catch((error) => fail?.(error));
475
480
  });
476
481
  try {
477
- await this.submit(prompt);
482
+ await this.submit(prompt, options.throwIfCancelled);
478
483
  return await finished;
479
484
  }
480
485
  finally {
@@ -0,0 +1,28 @@
1
+ import type { HostState } from './host-controls.js';
2
+ import { type ChatSetupOperation } from './chat-setup.js';
3
+ export interface SetupProcess {
4
+ exited: Promise<number | undefined>;
5
+ cancel(): void;
6
+ }
7
+ export interface SetupFlowHost {
8
+ state(): HostState;
9
+ refresh(): Promise<void>;
10
+ launch(kind: 'login' | 'install', harness: string, current: () => boolean): Promise<SetupProcess>;
11
+ changed(): void;
12
+ log(message: string): void;
13
+ }
14
+ export declare class ChatSetupFlow {
15
+ private readonly host;
16
+ readonly operations: Map<string, ChatSetupOperation>;
17
+ private processes;
18
+ private disposed;
19
+ notice: string | undefined;
20
+ constructor(host: SetupFlowHost);
21
+ begin(harness: string): ChatSetupOperation;
22
+ cancel(harness: string, id: string): boolean;
23
+ dispose(): void;
24
+ private current;
25
+ private update;
26
+ private refresh;
27
+ private run;
28
+ }
@@ -0,0 +1,127 @@
1
+ /** One user-requested install/sign-in operation per agent. No credentials cross this boundary. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { setupAgents } from './chat-setup.js';
4
+ export class ChatSetupFlow {
5
+ host;
6
+ operations = new Map();
7
+ processes = new Map();
8
+ disposed = false;
9
+ notice;
10
+ constructor(host) {
11
+ this.host = host;
12
+ }
13
+ begin(harness) {
14
+ if (this.disposed)
15
+ throw new Error('Chat setup has closed.');
16
+ const existing = this.operations.get(harness);
17
+ if (existing?.phase === 'installing' || existing?.phase === 'signing-in')
18
+ return { ...existing };
19
+ const agent = setupAgents(this.host.state()).find(a => a.harness === harness);
20
+ if (!agent)
21
+ throw new Error('That coding agent is unavailable.');
22
+ const now = Date.now();
23
+ const operation = { id: randomUUID(), harness, phase: agent.signedIn ? 'signed-in' : agent.installed ? 'signing-in' : 'installing', startedAt: now, updatedAt: now };
24
+ this.operations.set(harness, operation);
25
+ this.host.changed();
26
+ if (!agent.signedIn)
27
+ void this.run(operation);
28
+ return { ...operation };
29
+ }
30
+ cancel(harness, id) {
31
+ const operation = this.operations.get(harness);
32
+ if (!operation || operation.id !== id || !['installing', 'signing-in'].includes(operation.phase))
33
+ return false;
34
+ // Retire ownership before disposing the terminal: its close callback is not a failed sign-in.
35
+ this.operations.delete(harness);
36
+ this.processes.get(id)?.cancel();
37
+ this.processes.delete(id);
38
+ this.host.changed();
39
+ return true;
40
+ }
41
+ dispose() {
42
+ this.disposed = true;
43
+ for (const operation of this.operations.values())
44
+ this.processes.get(operation.id)?.cancel();
45
+ this.processes.clear();
46
+ }
47
+ current(operation) {
48
+ return !this.disposed && this.operations.get(operation.harness)?.id === operation.id;
49
+ }
50
+ update(operation, phase) {
51
+ if (!this.current(operation))
52
+ return;
53
+ this.operations.set(operation.harness, { ...operation, phase, updatedAt: Date.now() });
54
+ this.host.changed();
55
+ }
56
+ async refresh(operation) {
57
+ await this.host.refresh();
58
+ if (!this.current(operation))
59
+ throw new Error('Sign-in cancelled.');
60
+ }
61
+ async run(operation) {
62
+ const current = () => this.current(operation);
63
+ let process;
64
+ try {
65
+ await this.refresh(operation);
66
+ let agent = setupAgents(this.host.state()).find(a => a.harness === operation.harness);
67
+ if (!agent?.installed) {
68
+ this.update(operation, 'installing');
69
+ process = await this.host.launch('install', operation.harness, current);
70
+ if (!current()) {
71
+ process.cancel();
72
+ return;
73
+ }
74
+ this.processes.set(operation.id, process);
75
+ const exitCode = await process.exited;
76
+ this.processes.delete(operation.id);
77
+ if (!current())
78
+ return;
79
+ if (exitCode !== 0)
80
+ throw new Error(`Installation ended ${exitCode === undefined ? 'without an exit status' : `with exit code ${exitCode}`}.`);
81
+ await this.refresh(operation);
82
+ agent = setupAgents(this.host.state()).find(a => a.harness === operation.harness);
83
+ if (!agent?.installed)
84
+ throw new Error('Installation completed, but the coding agent is not available to this editor.');
85
+ }
86
+ if (!agent.signedIn) {
87
+ this.update(operation, 'signing-in');
88
+ process = await this.host.launch('login', operation.harness, current);
89
+ if (!current()) {
90
+ process.cancel();
91
+ return;
92
+ }
93
+ this.processes.set(operation.id, process);
94
+ let ended = false;
95
+ let exitCode;
96
+ void process.exited.then(code => { ended = true; exitCode = code; });
97
+ // Readiness comes only from the host's native inventory. A successful
98
+ // process exit, a browser opening, and a protocol handshake are not login.
99
+ while (current()) {
100
+ await this.refresh(operation);
101
+ if (setupAgents(this.host.state()).some(a => a.harness === operation.harness && a.signedIn))
102
+ break;
103
+ if (ended)
104
+ throw new Error(`Sign-in ended ${exitCode === undefined ? 'without an exit status' : `with exit code ${exitCode}`}, without a signed-in account.`);
105
+ await new Promise(resolve => setTimeout(resolve, 1000));
106
+ }
107
+ }
108
+ if (!current())
109
+ return;
110
+ this.notice = operation.harness === 'codex' ? 'ChatGPT' : operation.harness === 'claude-code' ? 'Claude' : undefined;
111
+ this.update(operation, 'signed-in');
112
+ // Retire only the terminal this click launched. Its native inventory has
113
+ // already confirmed login; closing it never supplies that confirmation.
114
+ process?.cancel();
115
+ }
116
+ catch (error) {
117
+ if (current()) {
118
+ this.host.log(`Could not sign in with ${operation.harness}: ${String(error)}`);
119
+ this.update(operation, 'failed');
120
+ process?.cancel();
121
+ }
122
+ }
123
+ finally {
124
+ this.processes.delete(operation.id);
125
+ }
126
+ }
127
+ }
package/dist/view.d.ts CHANGED
@@ -170,14 +170,6 @@ export interface ProjectedTurn {
170
170
  /** Rendered parts for an assistant turn. */
171
171
  parts?: ChatPart[];
172
172
  }
173
- /**
174
- * A HOSTED harness runtime's transcript as chat turns.
175
- *
176
- * `HostedHarnessRuntime::attach` returns `history: Vec::new()` and puts the
177
- * whole conversation in `replay` — the canonical ChatMessage history belongs to
178
- * supercode's own agent loop, and a hosted harness has none. So a resumed
179
- * Claude Code session's turns are here or nowhere.
180
- */
181
173
  /**
182
174
  * A "user" message no person typed: Claude Code's `<task-notification>` (a background task reporting back) or the
183
175
  * editor's automatic tripwire nudge. Neither is shown as the person's words. A task notification is said as one
@@ -186,7 +178,7 @@ export interface ProjectedTurn {
186
178
  export declare function automaticPrompt(body: string): {
187
179
  notice?: string;
188
180
  } | undefined;
189
- export declare function projectReplayTurns(events: readonly FrontendEvent[], granted: ReadonlySet<string>, context: ProjectionContext): ProjectedTurn[];
181
+ export declare function projectReplayTurns(events: readonly FrontendEvent[], granted: ReadonlySet<string>, context: ProjectionContext, history?: readonly ProjectedTurn[]): ProjectedTurn[];
190
182
  /**
191
183
  * The attach snapshot's canonical history as chat turns. Bounded: when older
192
184
  * turns are omitted the view says so, the way the browser observer does.
package/dist/view.js CHANGED
@@ -650,14 +650,6 @@ function messageText(message) {
650
650
  .filter(Boolean)
651
651
  .join('\n');
652
652
  }
653
- /**
654
- * A HOSTED harness runtime's transcript as chat turns.
655
- *
656
- * `HostedHarnessRuntime::attach` returns `history: Vec::new()` and puts the
657
- * whole conversation in `replay` — the canonical ChatMessage history belongs to
658
- * supercode's own agent loop, and a hosted harness has none. So a resumed
659
- * Claude Code session's turns are here or nowhere.
660
- */
661
653
  /**
662
654
  * A "user" message no person typed: Claude Code's `<task-notification>` (a background task reporting back) or the
663
655
  * editor's automatic tripwire nudge. Neither is shown as the person's words. A task notification is said as one
@@ -675,8 +667,13 @@ export function automaticPrompt(body) {
675
667
  return {};
676
668
  return undefined;
677
669
  }
678
- export function projectReplayTurns(events, granted, context) {
679
- const turns = [];
670
+ export function projectReplayTurns(events, granted, context, history = []) {
671
+ // Join canonical history with its sequenced tail. Retained request/decision
672
+ // events can be re-sequenced past history_cursor with no originating turn.
673
+ // Before the replay supplies a prompt, give its parts their own runtime
674
+ // history turn rather than attributing old approvals to the latest answer.
675
+ const turns = history.map(turn => ({ ...turn, parts: turn.parts?.slice() }));
676
+ let replayTurn = false;
680
677
  let streamedText;
681
678
  for (const event of events) {
682
679
  if (event.kind === 'user_message') {
@@ -686,8 +683,10 @@ export function projectReplayTurns(events, granted, context) {
686
683
  if (automatic) {
687
684
  if (!automatic.notice)
688
685
  continue;
689
- if (turns.length === 0)
686
+ if (!replayTurn) {
690
687
  turns.push({ role: 'user', prompt: HISTORY_MARKER_PROMPT });
688
+ replayTurn = true;
689
+ }
691
690
  const last = turns[turns.length - 1];
692
691
  const notice = { part: 'markdown', value: `\n\n${automatic.notice}\n\n` };
693
692
  if (last.role === 'assistant' && last.parts)
@@ -697,6 +696,7 @@ export function projectReplayTurns(events, granted, context) {
697
696
  continue;
698
697
  }
699
698
  turns.push({ role: 'user', prompt: clamp(stripControls(text(payload.text)), VSCODE_CHAT_LIMITS.partCharacters) });
699
+ replayTurn = true;
700
700
  continue;
701
701
  }
702
702
  const parts = resolveParts(projectEvent(event, context), granted);
@@ -712,8 +712,10 @@ export function projectReplayTurns(events, granted, context) {
712
712
  else {
713
713
  streamedText = undefined;
714
714
  }
715
- if (turns.length === 0)
715
+ if (!replayTurn) {
716
716
  turns.push({ role: 'user', prompt: HISTORY_MARKER_PROMPT });
717
+ replayTurn = true;
718
+ }
717
719
  const last = turns[turns.length - 1];
718
720
  if (last.role === 'assistant' && last.parts)
719
721
  last.parts.push(...parts);
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.50",
4
+ "version": "0.1.52",
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",