From 210b6fc3dc15b83ef91a91cdb1caaf365f71be96 Mon Sep 17 00:00:00 2001 From: thestreamcode Date: Sat, 1 Aug 2026 22:19:36 +0200 Subject: [PATCH 1/3] fix: release per-launch terminal listeners instead of pinning them to the context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every launch registered its shell-integration listener, fallback timer, and shell-execution listener on `context.subscriptions`, which VS Code drains only at deactivate. Repeated toolbar clicks therefore appended entries that were never removed, and each entry kept its launch's closure — including up to 64 KiB of captured terminal output — reachable for the rest of the session. Launch-scoped disposables now go through a registry that drops each entry as soon as it is disposed, with a single aggregate disposable on `context.subscriptions` guaranteeing cleanup at shutdown. Closing the launcher terminal before its command is sent also cancels that launch's pending wait, timer, and listener instead of letting them fire against a terminal that no longer exists. The registry lives in `command-utils.ts` as a pure helper with focused unit tests, per the repository guardrail for reusable decision logic. Co-Authored-By: Claude Opus 5 (1M context) --- src/command-utils.ts | 57 ++++++++++++++++++++ src/extension.ts | 103 ++++++++++++++++++++++++------------- test/command-utils.test.js | 82 +++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 35 deletions(-) diff --git a/src/command-utils.ts b/src/command-utils.ts index 8f6651d..008235e 100644 --- a/src/command-utils.ts +++ b/src/command-utils.ts @@ -238,6 +238,63 @@ export function shouldShowMissingCliGuidance(command: string, exitCode: number | return buildCommandNotFoundPatterns(command).some((pattern) => pattern.test(output)); } +/** Minimal structural shape of a VS Code disposable, kept free of the `vscode` module. */ +export type DisposableLike = { dispose(): void }; + +/** Tracks short-lived disposables and releases each entry as soon as it is disposed. */ +export type DisposableRegistry = { + /** Registers `disposable` and returns a handle that disposes and deregisters it exactly once. */ + track(disposable: DisposableLike): DisposableLike; + /** Disposes every entry that is still pending and empties the registry. */ + disposeAll(): void; + /** Number of entries still pending. */ + size(): number; +}; + +/** + * Creates a registry for disposables whose lifetime is shorter than the extension's. + * + * VS Code drains `ExtensionContext.subscriptions` only at deactivate, so pushing per-launch + * listeners and timers there grows that array on every launch and keeps their closures — including + * captured terminal output — alive for the rest of the session. Entries registered here deregister + * themselves the moment they are disposed, so the registry stays proportional to the work that is + * actually still in flight, while {@link DisposableRegistry.disposeAll} still guarantees cleanup at + * shutdown through a single `subscriptions` entry. + */ +export function createDisposableRegistry(): DisposableRegistry { + const pending = new Set(); + + return { + track(disposable: DisposableLike): DisposableLike { + const handle: DisposableLike = { + dispose: () => { + if (!pending.delete(handle)) { + return; + } + + disposable.dispose(); + }, + }; + + pending.add(handle); + + return handle; + }, + + disposeAll(): void { + for (const handle of [...pending]) { + handle.dispose(); + } + + pending.clear(); + }, + + size(): number { + return pending.size; + }, + }; +} + /** Resolves the terminal cwd from the active editor or the first workspace folder. */ export function resolveTerminalCwd( activeEditor: ActiveEditorLike | undefined, diff --git a/src/extension.ts b/src/extension.ts index 014f40a..ebbed5e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,7 @@ import { buildExtensionSettingsQuery, buildTerminalName, classifyBootstrapperRisk, + createDisposableRegistry, normalizeTerminalName, resolveBootstrapperPromptSelection, resolveCliCommandSetting, @@ -17,8 +18,20 @@ const SETTINGS_NAMESPACE = 'copilotCliLauncher'; const COPILOT_DOCS_URL = 'https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli'; const BOOTSTRAPPER_HANG_DOCS_URL = 'https://github.com/TheStreamCode/github-copilot-cli-launcher#known-issue-github-copilot-chat-bootstrapper-hang'; +/** How long a launch waits for terminal shell integration before falling back to `sendText`. */ +const SHELL_INTEGRATION_TIMEOUT_MS = 3000; + let terminalSequence = 1; +/** + * Listeners and timers that belong to a single launch. They must never be pushed straight onto + * `context.subscriptions`, which VS Code drains only at deactivate: that would append entries on + * every toolbar click and pin each launch's captured terminal output for the rest of the session. + * The registry drops entries as they are disposed, and `activate` registers one aggregate + * disposable so anything still in flight is released at shutdown. + */ +const launchDisposables = createDisposableRegistry(); + /** * Tracks whether the user has explicitly clicked "Launch Anyway" for the `possible-default` risk * (the bare `copilot` command, which may or may not actually resolve to the bootstrapper) during @@ -55,10 +68,15 @@ async function openCopilotInstallInstructions(): Promise { function executeCommandWithOptionalShellIntegration( terminal: vscode.Terminal, command: string, - context: vscode.ExtensionContext, onShellExecutionEnd?: (event: vscode.TerminalShellExecutionEndEvent, output: string) => void | Promise, ): void { let executionStarted = false; + let executionListener: vscode.Disposable | undefined; + + const stopWaitingForShellIntegration = () => { + shellIntegrationListener.dispose(); + fallbackTimer.dispose(); + }; const startExecution = (shellIntegration: vscode.TerminalShellIntegration) => { if (executionStarted) { @@ -66,39 +84,39 @@ function executeCommandWithOptionalShellIntegration( } executionStarted = true; - shellIntegrationListener.dispose(); - clearTimeout(fallbackHandle); + stopWaitingForShellIntegration(); let execution: vscode.TerminalShellExecution | undefined; let outputPromise: Promise | undefined; - const executionListener = onShellExecutionEnd - ? vscode.window.onDidEndTerminalShellExecution(async (endEvent) => { - if (endEvent.terminal !== terminal || (execution && endEvent.execution !== execution)) { - return; - } - - executionListener?.dispose(); - const output = outputPromise ? await outputPromise : ''; - await onShellExecutionEnd(endEvent, output); - }) - : undefined; - - if (executionListener) { - context.subscriptions.push(executionListener); + if (onShellExecutionEnd) { + executionListener = launchDisposables.track( + vscode.window.onDidEndTerminalShellExecution(async (endEvent) => { + if (endEvent.terminal !== terminal || (execution && endEvent.execution !== execution)) { + return; + } + + executionListener?.dispose(); + closeListener.dispose(); + const output = outputPromise ? await outputPromise : ''; + await onShellExecutionEnd(endEvent, output); + }), + ); } execution = shellIntegration.executeCommand(command); outputPromise = collectShellExecutionOutput(execution); }; - const shellIntegrationListener = vscode.window.onDidChangeTerminalShellIntegration((event) => { - if (event.terminal !== terminal) { - return; - } + const shellIntegrationListener = launchDisposables.track( + vscode.window.onDidChangeTerminalShellIntegration((event) => { + if (event.terminal !== terminal) { + return; + } - startExecution(event.shellIntegration); - }); + startExecution(event.shellIntegration); + }), + ); const fallbackHandle = setTimeout(() => { if (terminal.shellIntegration) { @@ -107,19 +125,30 @@ function executeCommandWithOptionalShellIntegration( } executionStarted = true; - shellIntegrationListener.dispose(); + stopWaitingForShellIntegration(); + closeListener.dispose(); terminal.sendText(command, true); - }, 3000); + }, SHELL_INTEGRATION_TIMEOUT_MS); + + const fallbackTimer = launchDisposables.track({ dispose: () => clearTimeout(fallbackHandle) }); + + // A terminal the user already closed can neither run the command nor report that it is missing. + const closeListener = launchDisposables.track( + vscode.window.onDidCloseTerminal((closedTerminal) => { + if (closedTerminal !== terminal) { + return; + } + + executionStarted = true; + stopWaitingForShellIntegration(); + executionListener?.dispose(); + closeListener.dispose(); + }), + ); if (terminal.shellIntegration) { startExecution(terminal.shellIntegration); - return; } - - context.subscriptions.push( - shellIntegrationListener, - { dispose: () => clearTimeout(fallbackHandle) }, - ); } async function handleMissingCopilot(cliCommand: string): Promise { @@ -184,11 +213,10 @@ async function confirmBootstrapperLaunch(cliCommand: string, context: vscode.Ext return outcome.proceed; } -function watchForMissingCopilot(terminal: vscode.Terminal, cliCommand: string, context: vscode.ExtensionContext): void { +function watchForMissingCopilot(terminal: vscode.Terminal, cliCommand: string): void { executeCommandWithOptionalShellIntegration( terminal, cliCommand, - context, async (endEvent, output) => { if (shouldShowMissingCliGuidance(cliCommand, endEvent.exitCode, output)) { await handleMissingCopilot(cliCommand); @@ -239,7 +267,7 @@ export function activate(context: vscode.ExtensionContext): void { cwd, }); terminal.show(); - watchForMissingCopilot(terminal, cliCommand, context); + watchForMissingCopilot(terminal, cliCommand); void vscode.window.setStatusBarMessage(`Started ${terminalBaseName}`, 2500); }); @@ -247,8 +275,13 @@ export function activate(context: vscode.ExtensionContext): void { await openExtensionSettings(context); }); - context.subscriptions.push(openCliCommand, openSettingsCommand); + context.subscriptions.push( + openCliCommand, + openSettingsCommand, + { dispose: () => launchDisposables.disposeAll() }, + ); } export function deactivate(): void { + launchDisposables.disposeAll(); } diff --git a/test/command-utils.test.js b/test/command-utils.test.js index 31728b1..77637d2 100644 --- a/test/command-utils.test.js +++ b/test/command-utils.test.js @@ -15,8 +15,17 @@ const { classifyBootstrapperRisk, shouldConfirmBootstrapperLaunch, resolveBootstrapperPromptSelection, + createDisposableRegistry, } = require('../out/command-utils.js'); +/** Builds a disposable that records how many times it was disposed. */ +function countingDisposable() { + const state = { disposeCount: 0 }; + state.disposable = { dispose: () => { state.disposeCount += 1; } }; + + return state; +} + // appendBoundedOutput test('appendBoundedOutput retains recent diagnostics without unbounded growth', () => { assert.equal(appendBoundedOutput('12345', '67890', 8), '34567890'); @@ -262,6 +271,79 @@ test('resolveBootstrapperPromptSelection blocks without acknowledging on a bare ); }); +// createDisposableRegistry +test('createDisposableRegistry starts empty', () => { + assert.equal(createDisposableRegistry().size(), 0); +}); + +test('createDisposableRegistry disposes the tracked disposable through its handle', () => { + const registry = createDisposableRegistry(); + const entry = countingDisposable(); + + const handle = registry.track(entry.disposable); + assert.equal(registry.size(), 1); + assert.equal(entry.disposeCount, 0); + + handle.dispose(); + assert.equal(entry.disposeCount, 1); +}); + +test('createDisposableRegistry releases entries as they are disposed instead of growing per launch', () => { + const registry = createDisposableRegistry(); + + for (let launch = 0; launch < 100; launch += 1) { + const entry = countingDisposable(); + registry.track(entry.disposable).dispose(); + assert.equal(entry.disposeCount, 1); + } + + assert.equal(registry.size(), 0); +}); + +test('createDisposableRegistry ignores repeated disposal of the same handle', () => { + const registry = createDisposableRegistry(); + const entry = countingDisposable(); + + const handle = registry.track(entry.disposable); + handle.dispose(); + handle.dispose(); + + assert.equal(entry.disposeCount, 1); + assert.equal(registry.size(), 0); +}); + +test('createDisposableRegistry keeps independent entries isolated', () => { + const registry = createDisposableRegistry(); + const first = countingDisposable(); + const second = countingDisposable(); + + const firstHandle = registry.track(first.disposable); + registry.track(second.disposable); + firstHandle.dispose(); + + assert.equal(first.disposeCount, 1); + assert.equal(second.disposeCount, 0); + assert.equal(registry.size(), 1); +}); + +test('createDisposableRegistry disposeAll drains everything still pending exactly once', () => { + const registry = createDisposableRegistry(); + const disposed = countingDisposable(); + const pending = countingDisposable(); + + registry.track(disposed.disposable).dispose(); + registry.track(pending.disposable); + + registry.disposeAll(); + + assert.equal(disposed.disposeCount, 1); + assert.equal(pending.disposeCount, 1); + assert.equal(registry.size(), 0); + + registry.disposeAll(); + assert.equal(pending.disposeCount, 1); +}); + // resolveTerminalCwd test('resolveTerminalCwd uses the active editor workspace when available', () => { const workspace = { From 28a99ce9fec7be438ff5cffc08139e3c819d983a Mon Sep 17 00:00:00 2001 From: thestreamcode Date: Sat, 1 Aug 2026 22:19:46 +0200 Subject: [PATCH 2/3] release: v0.2.10 Bump the patch version across package.json, package-lock.json, CITATION.cff, the metadata test assertion, and the Publish Open VSX workflow default tag, and record the release in CHANGELOG.md. Add a metadata regression test asserting that `extension.ts` keeps exactly one `context.subscriptions.push` call, so per-launch listeners cannot drift back onto the array VS Code drains only at deactivate. Expand AGENTS.md with the stack, runtime, package manager, generated files, asset rules, compatibility contract, secret handling, branch protection, versioning and publishing channels, and the mandatory validation gate. Correct the version synchronization list in docs/RELEASING.md. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish-open-vsx.yml | 2 +- AGENTS.md | 146 +++++++++++++++++++++---- CHANGELOG.md | 19 +++- CITATION.cff | 2 +- docs/RELEASING.md | 6 +- package-lock.json | 4 +- package.json | 2 +- test/metadata.test.js | 15 ++- 8 files changed, 163 insertions(+), 33 deletions(-) diff --git a/.github/workflows/publish-open-vsx.yml b/.github/workflows/publish-open-vsx.yml index b4c340d..693e945 100644 --- a/.github/workflows/publish-open-vsx.yml +++ b/.github/workflows/publish-open-vsx.yml @@ -6,7 +6,7 @@ on: tag: description: Existing GitHub Release tag to publish required: true - default: v0.2.9 + default: v0.2.10 type: string permissions: diff --git a/AGENTS.md b/AGENTS.md index c188669..3c1f201 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,51 +6,151 @@ These instructions apply to the entire repository. ## Project Overview -Copilot CLI Launcher is a small, privacy-first VS Code extension. It creates a fresh side terminal and starts a user-configured GitHub Copilot CLI command. The extension must remain unofficial, local-only, and free of telemetry, installers, or background network behavior. +Copilot CLI Launcher is a small, privacy-first VS Code extension published as a VSIX. It creates a +fresh side terminal and starts a user-configured GitHub Copilot CLI command. The extension must +remain unofficial, local-only, and free of telemetry, installers, or background network behavior. + +The repository is **public** and MIT licensed. Treat everything committed here as world-readable: +no internal endpoints, customer data, credentials, or unreleased claims. + +## Stack and Runtime + +- TypeScript compiled by `tsc` (no bundler) to CommonJS-compatible `out/` via `module: NodeNext`, + `target: ES2022`, `strict: true`. +- Runtime: the VS Code extension host. Declared engine floor is `^1.103.0`; `@types/vscode` must + stay aligned with it. +- Tooling runtime: **Node.js 22** (the version CI uses). +- Package manager: **npm**, with the committed `package-lock.json`. Do not introduce a second + package manager, lockfile, or a `packageManager` field — a metadata test asserts its absence. +- No runtime dependencies. Everything in `devDependencies` is build, test, or packaging tooling. ## Source Layout -- `src/extension.ts`: VS Code activation, commands, terminal lifecycle, prompts, and shell-integration wiring. -- `src/command-utils.ts`: pure command parsing, risk classification, output bounding, naming, and workspace resolution helpers. -- `test/`: Node unit and metadata tests plus the VS Code integration smoke test. -- `media/`: packaged product artwork. -- `.github/`: issue templates, pull request guidance, and CI. +- `src/extension.ts`: VS Code activation, commands, terminal lifecycle, prompts, and + shell-integration wiring. The only module allowed to import `vscode`. +- `src/command-utils.ts`: pure command parsing, risk classification, output bounding, naming, + disposable bookkeeping, and workspace resolution helpers. Must stay free of `vscode` imports so + it is unit-testable under plain Node. +- `test/command-utils.test.js`: `node:test` unit tests against the compiled `out/command-utils.js`. +- `test/metadata.test.js`: asserts package metadata, ignore rules, documentation, CI, and + source-level invariants. Version-sensitive — update it with every version bump. +- `test/integration/`: VS Code integration smoke test driven by `@vscode/test-electron`. +- `media/icon.png`: the single packaged product artwork, used for both the Marketplace listing and + the editor-title command icon. +- `docs/`: engineering notes and the release checklist. +- `.github/`: issue templates, pull request guidance, CI, and the Open VSX publish workflow. ## Required Commands -Use Node.js 22 and install the committed dependency graph: - ```powershell npm ci npm run check npm run audit ``` -Build a release candidate only after those commands pass: +- `npm run check` = clean compile, unit + metadata tests, VS Code integration smoke test, and + `vsce ls` package-content validation. It is the single gate to run before any commit. +- Focused runs: `npm run test:unit`, `npm run test:integration`, `npm run test:metadata`, + `npm run test:command-utils`. +- Build a release candidate only after those pass: ```powershell -npm run package -- --out .vsce/review.vsix +npm run package -- --out .vsce/vscode-copilot-cli-launcher-.vsix ``` -Do not commit `node_modules/`, `out/`, `.vscode-test/`, `.vsce/`, logs, or generated `.vsix` files. +## Generated and Ignored Files + +Never commit or hand-edit: `node_modules/`, `out/`, `out/**/*.map`, `.vscode-test/`, `.vsce/`, +`*.vsix`, `*.tsbuildinfo`, logs. `out/` is wiped by `npm run clean` on every compile, so edits +there are lost. Edit `package-lock.json` only through npm. ## Engineering Guardrails -- Keep TypeScript strict and move reusable decision logic into pure functions in `command-utils.ts` with focused tests. -- Preserve Workspace Trust checks and resolve `copilotCliLauncher.cliCommand` from user or machine configuration only. A workspace must not be able to choose the command that is executed. -- Send the configured command exactly once. Shell-integration and fallback paths must remain mutually exclusive. +- Keep TypeScript strict and move reusable decision logic into pure functions in + `command-utils.ts` with focused tests. +- Preserve Workspace Trust checks and resolve `copilotCliLauncher.cliCommand` from user or machine + configuration only. A workspace must not be able to choose the command that is executed. +- Send the configured command exactly once. Shell-integration and fallback paths must remain + mutually exclusive. +- Register per-launch listeners and timers through the disposable registry in + `command-utils.ts`, never directly on `context.subscriptions`. VS Code drains `subscriptions` + only at deactivate, so pushing there on every launch grows that array without bound and pins each + launch's captured terminal output. A metadata test enforces a single `context.subscriptions.push` + call in `extension.ts`. - Keep captured terminal output bounded and in memory only; never persist command output. -- Do not add telemetry, analytics, filesystem scanning, child-process installers, package-manager execution, or automatic downloads. -- Use official GitHub documentation for Copilot CLI installation guidance. Do not bundle GitHub or Microsoft trademarks or artwork without permission. +- Do not add telemetry, analytics, filesystem scanning, child-process installers, package-manager + execution, or automatic downloads. `extension.ts` must not import `node:child_process`, + `node:fs`, `node:os`, or `node:path` — a metadata test asserts this. +- Use official GitHub documentation for Copilot CLI installation guidance. Do not bundle GitHub or + Microsoft trademarks or artwork without permission. - Update tests, `README.md`, and `CHANGELOG.md` whenever user-visible behavior changes. -## GitHub and CI +## Compatibility Rules -- Keep workflow permissions minimal and pin third-party actions to immutable commit SHAs. -- Do not weaken `main` branch protection, required checks, review requirements, secret scanning, or push protection. -- Never place publishing tokens in files, commands, logs, pull requests, or shell history. Use environment variables supplied through an approved secret store. -- Do not commit, push, tag, create a release, or publish to a registry unless the user explicitly requests that action. +- No breaking changes without an explicit, authorized major-version decision. Setting IDs + (`copilotCliLauncher.cliCommand`, `copilotCliLauncher.terminalName`), command IDs + (`copilotCliLauncher.openCli`, `copilotCliLauncher.openSettings`), the publisher `mikesoft`, and + the extension name `vscode-copilot-cli-launcher` are part of the public contract. +- Raise `engines.vscode` only when a newly used API actually requires it, and align + `@types/vscode` in the same change. +- The extension supports Windows, macOS, and Linux. Keep path and quoting handling + platform-neutral and covered by tests. -## Releases +## Assets -Keep the version synchronized in `package.json`, `package-lock.json`, `CITATION.cff`, tests, and `CHANGELOG.md`. Follow [`docs/RELEASING.md`](docs/RELEASING.md), wait for required CI checks, and verify every target registry after publication. +`media/icon.png` is the product artwork and must not be redesigned, recolored, renamed, or moved. +It may only be losslessly recompressed, preserving dimensions, format, and appearance. A metadata +test requires it to stay at least 256x256 and under 256 KiB. + +## Environment Variables and Secrets + +The extension itself reads no environment variables and stores no secrets. Publishing tokens +(`VSCE_PAT` for the Visual Studio Marketplace, `OVSX_PAT` for Open VSX) exist only as CI secrets or +an operator's local environment. Never place them in files, command arguments, logs, pull requests, +or shell history, and never echo their values — check only for presence. + +## GitHub and CI + +- `main` is protected: required status checks `check (ubuntu-latest)` and `check (windows-latest)`, + one approving review, linear history, and required conversation resolution. Work on a dedicated + branch and open a pull request; never force-push, rewrite history, or bypass protection. +- Keep workflow permissions minimal and pin third-party actions to immutable commit SHAs. +- Do not weaken branch protection, required checks, review requirements, secret scanning, or push + protection. +- Do not commit, push, tag, create a release, or publish to a registry unless the user explicitly + requests that action. + +## Versioning and Releases + +Semantic versioning. Keep the version synchronized across `package.json`, `package-lock.json` +(both the root and `packages[""]` entries, via `npm version --no-git-tag-version`), `CITATION.cff`, +the version assertion in `test/metadata.test.js`, the default `tag` input of +`.github/workflows/publish-open-vsx.yml`, and `CHANGELOG.md` (Keep a Changelog format, with the +comparison links at the bottom). + +Follow [`docs/RELEASING.md`](docs/RELEASING.md), wait for required CI checks, and verify every +target registry after publication: + +- Visual Studio Marketplace: `mikesoft.vscode-copilot-cli-launcher`, published with `vsce`. +- Open VSX: `mikesoft/vscode-copilot-cli-launcher`, published by the **Publish Open VSX** workflow + from an existing GitHub Release asset, or with `ovsx` as an authorized local fallback. + +## Mandatory Validation Before Handoff + +1. `npm ci` succeeds from the committed lockfile. +2. `npm run check` passes end to end (compile, unit, metadata, integration, `vsce ls`). +3. `npm run audit` reports no high-severity advisories. +4. `npm run package` produces a VSIX whose file list contains only `out/*.js`, `media/icon.png`, + `package.json`, and the root documentation and licence files — no sources, tests, maps, + lockfile, or local tooling. +5. Version references are synchronized and `CHANGELOG.md` has a dated entry with real changes only. + +## Instructions for AI Agents + +- Read this file, `README.md`, `CONTRIBUTING.md`, and `docs/RELEASING.md` before changing anything. +- Prefer small, reviewable diffs. Do not perform cosmetic refactors that enlarge the diff without + a concrete benefit. +- Never invent changelog entries, badges, statistics, links, or release results. Verify every + command and URL you document. +- Never delete user data, local `.vsce/` artwork sources, or untracked local files. +- Report failures with their real output instead of working around checks. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff58d9..f0ad92e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.2.10] - 2026-08-01 + +### Fixed + +- Stopped registering every launch's shell-integration listener, fallback timer, and shell-execution listener on the extension context, which VS Code releases only at deactivate. Repeated toolbar clicks appended entries that were never removed and kept each launch's captured terminal output reachable for the rest of the session; launch-scoped disposables now deregister themselves as soon as they are disposed. +- Closing the launcher terminal before its command is sent now cancels that launch's pending shell-integration wait, fallback timer, and missing-command listener instead of leaving them to fire against a terminal that no longer exists. + +### Changed + +- Extracted the launch disposable registry into `command-utils.ts` as a pure, unit-tested helper and named the shell-integration fallback delay constant. +- Documented the packaging, publishing, validation, and asset rules for the repository in `AGENTS.md`. + +### Added + +- Unit tests covering the disposable registry and a metadata regression test asserting that per-launch listeners stay out of the never-drained extension subscriptions array. + ## [0.2.9] - 2026-08-01 ### Changed @@ -186,5 +202,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Smart working directory resolution from the active editor. - Windows, macOS, and Linux support. -[Unreleased]: https://github.com/TheStreamCode/github-copilot-cli-launcher/compare/v0.2.9...HEAD +[Unreleased]: https://github.com/TheStreamCode/github-copilot-cli-launcher/compare/v0.2.10...HEAD +[0.2.10]: https://github.com/TheStreamCode/github-copilot-cli-launcher/compare/v0.2.9...v0.2.10 [0.2.9]: https://github.com/TheStreamCode/github-copilot-cli-launcher/compare/v0.2.8...v0.2.9 diff --git a/CITATION.cff b/CITATION.cff index 4c95474..d3a85b5 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,5 +5,5 @@ authors: - family-names: Gasperini given-names: Michael url: "https://github.com/TheStreamCode/github-copilot-cli-launcher" -version: "0.2.9" +version: "0.2.10" license: MIT diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c03189e..a51556a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -6,10 +6,10 @@ Use this checklist for stable releases. Publishing is an external write and requ Choose the next semantic version and keep it synchronized in: -- `package.json` -- `package-lock.json` +- `package.json` and `package-lock.json` (use `npm version --no-git-tag-version`) - `CITATION.cff` -- version-sensitive metadata tests +- the version assertion in `test/metadata.test.js` +- the default `tag` input of `.github/workflows/publish-open-vsx.yml` - `CHANGELOG.md` Move completed changelog entries from `Unreleased` into a dated release section and update the comparison links. diff --git a/package-lock.json b/package-lock.json index 420f340..f7a6af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-copilot-cli-launcher", - "version": "0.2.9", + "version": "0.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-copilot-cli-launcher", - "version": "0.2.9", + "version": "0.2.10", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/package.json b/package.json index 4e8bec4..2840c60 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Copilot CLI Launcher — Run GitHub Copilot in a Side Terminal", "description": "Launch the GitHub Copilot CLI coding agent in a side terminal from your editor toolbar — one click, fresh terminal. Unofficial; works in VS Code, Cursor & Windsurf on Windows, macOS & Linux.", "publisher": "mikesoft", - "version": "0.2.9", + "version": "0.2.10", "private": true, "repository": { "type": "git", diff --git a/test/metadata.test.js b/test/metadata.test.js index f0dfa83..a50df44 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -42,7 +42,7 @@ test('package metadata uses Copilot CLI Launcher branding', () => { assert.equal(packageJson.displayName, 'Copilot CLI Launcher — Run GitHub Copilot in a Side Terminal'); assert.equal(packageJson.description, 'Launch the GitHub Copilot CLI coding agent in a side terminal from your editor toolbar — one click, fresh terminal. Unofficial; works in VS Code, Cursor & Windsurf on Windows, macOS & Linux.'); - assert.equal(packageJson.version, '0.2.9'); + assert.equal(packageJson.version, '0.2.10'); assert.equal(packageJson.private, true); assert.equal(packageJson.packageManager, undefined); assert.equal(packageJson.icon, 'media/icon.png'); @@ -202,6 +202,19 @@ test('CI workflow validates the extension with npm on Windows and Linux', () => assert.match(workflow, /persist-credentials: false/); }); +test('per-launch listeners stay out of the never-drained extension subscriptions array', () => { + const extensionSource = readText('src/extension.ts'); + const subscriptionPushes = extensionSource.match(/context\.subscriptions\.push\(/g) ?? []; + + // VS Code drains `context.subscriptions` only at deactivate, so anything registered per launch + // must go through the disposable registry instead. Exactly one push, inside `activate`, is + // expected: the two commands plus the aggregate registry teardown. + assert.equal(subscriptionPushes.length, 1); + assert.match(extensionSource, /createDisposableRegistry\(\)/); + assert.match(extensionSource, /launchDisposables\.track\(/); + assert.match(extensionSource, /launchDisposables\.disposeAll\(\)/); +}); + test('missing CLI guidance only opens the official installation documentation', () => { const packageJson = readPackageJson(); const extensionSource = readText('src/extension.ts'); From 224fdb4ef5f6a1c1d7eb2243b95096e71ea6fb73 Mon Sep 17 00:00:00 2001 From: thestreamcode Date: Sun, 2 Aug 2026 08:37:37 +0200 Subject: [PATCH 3/3] chore: harden release metadata validation --- AGENTS.md | 5 +++++ CHANGELOG.md | 1 + package.json | 4 ++++ test/metadata.test.js | 24 ++++++++++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3c1f201..3fb65c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,9 @@ no internal endpoints, customer data, credentials, or unreleased claims. - Tooling runtime: **Node.js 22** (the version CI uses). - Package manager: **npm**, with the committed `package-lock.json`. Do not introduce a second package manager, lockfile, or a `packageManager` field — a metadata test asserts its absence. +- npm install scripts are denied unless their exact package version appears in `allowScripts`. + Review the script before approving it with `npm approve-scripts`; never broaden an existing + approval or carry it to a new dependency version without re-reviewing that version. - No runtime dependencies. Everything in `devDependencies` is build, test, or packaging tooling. ## Source Layout @@ -144,6 +147,8 @@ target registry after publication: `package.json`, and the root documentation and licence files — no sources, tests, maps, lockfile, or local tooling. 5. Version references are synchronized and `CHANGELOG.md` has a dated entry with real changes only. +6. `npm ci` emits no pending `allowScripts` warning; any newly requested install script is reviewed + and pinned to its exact package version before approval. ## Instructions for AI Agents diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ad92e..0dee018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Extracted the launch disposable registry into `command-utils.ts` as a pure, unit-tested helper and named the shell-integration fallback delay constant. - Documented the packaging, publishing, validation, and asset rules for the repository in `AGENTS.md`. +- Recorded the two reviewed release-tool install scripts with exact-version npm `allowScripts` entries and added a metadata regression test that keeps the manifest, lockfile, citation, changelog, and Open VSX workflow version synchronized. ### Added diff --git a/package.json b/package.json index 2840c60..e41c274 100644 --- a/package.json +++ b/package.json @@ -121,5 +121,9 @@ "@vscode/test-electron": "^3.0.0", "@vscode/vsce": "^3.9.2", "typescript": "^7.0.2" + }, + "allowScripts": { + "@vscode/vsce-sign@2.0.9": true, + "keytar@7.9.0": true } } diff --git a/test/metadata.test.js b/test/metadata.test.js index a50df44..5ed1c29 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -16,6 +16,11 @@ function readPackageJson() { return JSON.parse(readText('package.json')); } +/** Escapes a literal value before embedding it in a regular expression. */ +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** Returns non-empty ignore entries for line-based assertions. */ function readIgnoreEntries(relativePath) { return readText(relativePath) @@ -101,6 +106,25 @@ test('package scripts use deterministic local tooling entry points', () => { assert.equal(packageJson.scripts.audit, 'npm audit --audit-level=high'); assert.equal(packageJson.scripts.check, 'npm run compile && node --test test/*.test.js && node ./test/integration/runTest.js && node ./node_modules/@vscode/vsce/vsce ls'); assert.equal(packageJson.scripts.package, 'node ./node_modules/@vscode/vsce/vsce package'); + assert.deepEqual(packageJson.allowScripts, { + '@vscode/vsce-sign@2.0.9': true, + 'keytar@7.9.0': true, + }); +}); + +test('release metadata versions stay synchronized', () => { + const packageJson = readPackageJson(); + const packageLock = JSON.parse(readText('package-lock.json')); + const citation = readText('CITATION.cff'); + const changelog = readText('CHANGELOG.md'); + const openVsxWorkflow = readText('.github/workflows/publish-open-vsx.yml'); + const version = escapeRegExp(packageJson.version); + + assert.equal(packageLock.version, packageJson.version); + assert.equal(packageLock.packages[''].version, packageJson.version); + assert.match(citation, new RegExp(`^version: ["']?${version}["']?\\s*$`, 'm')); + assert.match(changelog, new RegExp(`^## \\[${version}\\] - \\d{4}-\\d{2}-\\d{2}$`, 'm')); + assert.match(openVsxWorkflow, new RegExp(`^\\s*default: v${version}\\s*$`, 'm')); }); test('README is organized around user-facing setup, configuration, and troubleshooting', () => {