diff --git a/AGENTS.md b/AGENTS.md index dcaebfc..a86f22c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,23 +2,59 @@ ## Project scope -Codex CLI Launcher is an unofficial VS Code extension. It creates a side terminal and launches a user-configured Codex CLI command. Keep the extension small, cross-platform, and explicit about its unofficial status. +Codex CLI Launcher is an unofficial VS Code extension published to the Visual Studio Marketplace and Open VSX as `mikesoft.vscode-codex-cli-launcher`. It creates a side terminal and launches a user-configured Codex CLI command. Keep the extension small, cross-platform, and explicit about its unofficial status. + +The repository is public and MIT licensed. Assume every change, comment, and commit message is world-readable. + +## Stack and runtime + +- TypeScript compiled with the repository-local `typescript` compiler to CommonJS-compatible `NodeNext` output under `out/`. +- `npm` with the committed `package-lock.json` is the only supported package manager. Do not introduce a second lockfile or package manager. +- Node.js 22 or newer. CI runs Node 22 on `windows-latest` and `ubuntu-latest`. +- Minimum supported editor is VS Code `^1.103.0`. `@types/vscode` is pinned to exactly `1.103.0` so the compiler cannot accept APIs that do not exist on the declared floor. +- There is no linter or formatter. `.editorconfig` (LF, UTF-8, two-space indent, final newline) plus `tsc` in `strict` mode with `noUnusedLocals` and `noUnusedParameters` are the only static checks. ## Architecture - `src/extension.ts` owns VS Code API integration, terminal lifecycle, workspace-trust checks, settings, and user messages. -- `src/command-utils.ts` contains pure command, naming, output-buffer, and workspace-resolution helpers. Put testable logic here instead of embedding it in activation handlers. -- `test/*.test.js` contains unit, metadata, packaging, and security-invariant tests. -- `test/integration/` runs the extension in the minimum supported VS Code release by default. -- `out/`, `.vscode-test/`, and `*.vsix` are generated locally and must not be committed. +- `src/command-utils.ts` contains pure command, naming, output-buffer, launch-session, and workspace-resolution helpers. Put testable logic here instead of embedding it in activation handlers. +- `test/*.test.js` contains unit, metadata, packaging, lifecycle, and security-invariant tests. They run against compiled `out/`, so compile before running them directly. +- `test/integration/` runs the extension in the minimum supported VS Code release by default. Override with `VSCODE_TEST_VERSION`. +- `docs/` holds maintainer documentation and is excluded from the VSIX. +- `media/` holds the packaged Marketplace and toolbar artwork. + +## Repository structure + +``` +.github/ workflows, issue forms, PR template, CODEOWNERS, funding +docs/ architecture and release documentation (not packaged) +media/ icon.png (Marketplace) and launcher-mark.svg (toolbar) +src/ extension.ts and command-utils.ts +test/ unit and metadata tests plus the VS Code integration suite +out/ generated compiler output (never edit, never commit) +``` + +## Commands -## Setup and verification +| Purpose | Command | +| --- | --- | +| Install | `npm ci` | +| Compile and type-check | `npm run compile` | +| Watch | `npm run watch` | +| Unit tests | `npm run test:unit` | +| Integration smoke test | `npm run test:integration` | +| All tests | `npm test` | +| Full validation (compile, tests, integration, packaged file list) | `npm run check` | +| Dependency audit | `npm run audit` | +| Build the VSIX | `npm run package` | -- Use Node.js 22 or newer and npm with the committed `package-lock.json`. -- Install reproducibly with `npm ci`. -- Run `npm run check` after source, manifest, workflow, documentation, or packaging changes. -- Run `npm run audit` after changing dependencies or the lockfile. -- Run `npm run package` for release-facing changes and inspect the packaged file list. +There is no dev server, no deploy step, and no publish script. Publishing is a manual maintainer action through `vsce` and `ovsx`; see `docs/releasing.md`. + +## Launch lifecycle + +Only `activate` may register on `context.subscriptions`: the two commands and the terminal-close listener. VS Code releases that array on deactivation only, so anything pushed per launch would accumulate for the whole session. + +Every launch instead creates a disposal scope with `createLaunchSession`. Register launch-scoped listeners and timers on that scope. A scope ends when the command finishes, when the fallback `sendText` path runs, or when its terminal closes; `deactivate` drains whatever is left. ## Security invariants @@ -26,19 +62,56 @@ Codex CLI Launcher is an unofficial VS Code extension. It creates a side termina - Never execute a command sourced from workspace settings. `cliCommand` must remain machine-scoped and resolved from user-level configuration. - Require a trusted workspace before creating a terminal or sending a command. - Treat `cliCommand` as an intentional shell command, preserve quoted executable paths, and do not silently rewrite it. -- Keep shell-output capture bounded and limited to the missing-Codex detection path. Do not log terminal output or add telemetry. +- Keep shell-output capture bounded and limited to the missing-Codex detection path. Custom commands must not have their output read at all. Do not log terminal output or add telemetry. +- `src/extension.ts` must contain exactly one external URL, the official Codex CLI documentation link. Tests enforce this. - Do not add official OpenAI or Codex logos or imply endorsement. +## Environment variables and secrets + +The extension reads no environment variables at runtime. `VSCODE_TEST_VERSION` only selects the VS Code build used by the integration test. + +Marketplace and Open VSX tokens (`VSCE_PAT`, `OVSX_PAT`) belong to the maintainer's local environment. Never commit them, never echo them, never add them to workflows, issue comments, or release notes. No repository secret is required by any workflow in this repository. + +## Generated and protected files + +- `out/`, `.vscode-test/`, and `*.vsix` are generated locally and must not be committed or edited. +- `package-lock.json` is maintained by npm. Change it only through npm commands. +- `media/icon.png` and `media/launcher-mark.svg` are hand-authored published artwork. Do not regenerate, redraw, recolor, resize, rename, or move them, and do not add a script that writes to `media/`. Optimizing byte size is acceptable only when the rendered result, dimensions, transparency, format, and paths are preserved exactly. + +## Compatibility rules + +- No breaking changes. Setting ids, command ids, the publisher, and the extension name are part of the installed user's configuration and must stay stable. +- Do not raise `engines.vscode` without a real API requirement, and keep `@types/vscode` equal to that floor. +- Verify behavior on both Windows and POSIX shells when command parsing or terminal execution changes. +- Keep the version synchronized across `package.json`, `package-lock.json`, `CITATION.cff`, `README.md`, `CHANGELOG.md`, and `test/metadata.test.js`. + ## Change conventions - Keep changes focused and add regression tests for behavior changes. - Update `README.md` for user-visible behavior and `CHANGELOG.md` under `Unreleased` for notable changes. -- Keep the version synchronized across `package.json`, `package-lock.json`, `CITATION.cff`, and release-facing documentation when preparing a release. - Pin GitHub Actions to full commit SHAs and grant only the permissions each workflow needs. -- Use concise conventional commit subjects. Do not publish, tag, push, or create a release unless the maintainer explicitly requests it. +- Use concise conventional commit subjects. + +## Validation criteria + +Before proposing a change: + +1. `npm ci` +2. `npm run check` after any source, manifest, workflow, documentation, or packaging change +3. `npm run audit` after any dependency or lockfile change +4. `npm run package` for release-facing changes, then inspect the file list and confirm that sources, tests, workflows, `AGENTS.md`, source maps, and local artifacts stay out of the VSIX + +Do not disable, skip, or weaken a test to make validation pass. ## Code review rules - Flag any path that can bypass workspace trust, accept repository-controlled commands, execute installers, or expose terminal output. -- Verify behavior on Windows and POSIX shells when command parsing or terminal execution changes. -- Treat packaged-file changes as release changes: confirm that sources, tests, agent instructions, and local artifacts stay out of the VSIX. +- Flag any disposable registered on `context.subscriptions` outside `activate`. +- Treat packaged-file changes as release changes. + +## Instructions for AI agents + +- `main` is protected: it requires a pull request, one approving review, passing `validate (ubuntu-latest)` and `validate (windows-latest)` checks, and linear history. Never push to `main` directly, never force push, never rewrite history, and never approve or merge your own pull request. +- Do not publish, tag, or create a release unless the maintainer explicitly requests it. The published Marketplace and Open VSX versions must always match the tag, `package.json`, and `CHANGELOG.md`. +- Do not delete files under `media/`, `docs/`, or `.github/` without an explicit request. +- Report what you actually ran and what actually happened. Do not claim a published release you did not perform. diff --git a/CHANGELOG.md b/CHANGELOG.md index d489594..8855f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +## 0.1.9 - 2026-08-01 + +### Fixed + +- Scoped every launch to its own disposal unit, so launch-scoped listeners and the shell-integration fallback timer are released when the launch completes or its terminal closes. They were previously appended to the extension subscription list on every launch and only released on deactivation, so repeated launches grew that list without bound. +- Stopped reading terminal shell output for custom CLI commands. Output was still buffered for every launch even though only the Codex missing-CLI check consumes it, which did not match the documented invariant. + +### Added + +- Unit tests for the launch disposal scope and source-level regression tests for the extension activation lifecycle. + +### Changed + +- Moved the launch disposal scope into `src/command-utils.ts` so it is covered by unit tests instead of only by the integration smoke test. +- Documented the launch lifecycle in `docs/architecture.md` and `AGENTS.md`. + +### Removed + +- Removed the unused `scripts/generate-icon.js` placeholder generator and its `generate:icon` script. It had no callers and overwrote the published `media/icon.png` with a different placeholder image. The published artwork is unchanged. + ## 0.1.8 ### Changed diff --git a/CITATION.cff b/CITATION.cff index 8e98c51..66c0ca8 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,5 +5,5 @@ authors: - family-names: Gasperini given-names: Michael url: "https://github.com/TheStreamCode/codex-cli-launcher" -version: "0.1.8" +version: "0.1.9" license: MIT diff --git a/README.md b/README.md index 8786be4..3d1b230 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Codex CLI Launcher is an unofficial VS Code extension that opens OpenAI Codex CL Works on Windows, macOS, and Linux where Codex CLI is available to the integrated terminal. -Current release: `0.1.8`. +Current release: `0.1.9`. > **Disclaimer** > This extension is unofficial and is not affiliated with, endorsed by, sponsored by, or approved by OpenAI. "OpenAI", "Codex", and related names are trademarks of their respective owners. diff --git a/docs/architecture.md b/docs/architecture.md index eddf425..08929ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,11 +10,19 @@ Codex CLI Launcher is intentionally a small extension with two production module 4. The extension creates a side terminal and executes the command through terminal shell integration when available, falling back to `sendText` after a short wait. 5. For Codex executables only, the extension inspects a bounded window of shell output. If the executable is missing, it offers the official documentation link. +## Launch lifecycle + +Each launch owns a disposal scope created by `createLaunchSession`. The scope holds the shell-integration listener, the fallback timer, and the shell-execution listener for that launch only. + +A scope ends when its command finishes, when the fallback path sends the command without shell integration, or when its terminal closes. `deactivate` drains any scope that is still open. + +Only `activate` registers on `context.subscriptions`: the two commands and the terminal-close listener. VS Code releases that array on deactivation only, so per-launch resources must never be added to it. + ## Trust boundaries - Workspace files are untrusted input. They must not control `cliCommand` or bypass the workspace-trust gate. - The configured CLI command is trusted user intent and is sent to the integrated shell. The launcher does not parse arguments into a child process. -- Terminal output can contain sensitive data. It is never logged or transmitted, is not collected for custom commands, and is bounded while checking for a missing Codex executable. +- Terminal output can contain sensitive data. It is never logged or transmitted, is never read for custom commands, and is bounded while checking for a missing Codex executable. - Network access is limited to opening the official Codex CLI documentation at the user's request. The extension does not make background requests. ## Packaging diff --git a/docs/releasing.md b/docs/releasing.md index 6e8995f..883e115 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -5,7 +5,7 @@ Releases are maintainer-only and should start from a clean, up-to-date `main` br ## Prepare 1. Choose a semantic version and move relevant entries from `Unreleased` into that version in `CHANGELOG.md`. -2. Synchronize the version in `package.json`, `package-lock.json`, `CITATION.cff`, `README.md`, and any other release-facing documentation. +2. Synchronize the version in `package.json` and `package-lock.json` with `npm version --no-git-tag-version`, then update `CITATION.cff`, `README.md`, the version assertion in `test/metadata.test.js`, and any other release-facing documentation. 3. Run `npm ci`, `npm run check`, and `npm run audit`. 4. Run `npm run package` and inspect the VSIX file list. Confirm that source, tests, workflows, `AGENTS.md`, and local artifacts are absent. 5. Install the generated VSIX in a clean VS Code profile and smoke-test launching, settings, workspace trust, and the missing-CLI documentation prompt. diff --git a/package-lock.json b/package-lock.json index 2d39741..184d632 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-codex-cli-launcher", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-codex-cli-launcher", - "version": "0.1.8", + "version": "0.1.9", "license": "MIT", "devDependencies": { "@types/node": "^22.20.1", diff --git a/package.json b/package.json index 622950f..7b73817 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "displayName": "Codex CLI Launcher — Run OpenAI Codex in a Side Terminal", "description": "Launch the OpenAI Codex AI coding agent in a side terminal from your editor toolbar. Unofficial; works in VS Code, Cursor & Windsurf on Windows, macOS & Linux.", "publisher": "mikesoft", - "version": "0.1.8", + "version": "0.1.9", "repository": { "type": "git", "url": "https://github.com/TheStreamCode/codex-cli-launcher.git" @@ -113,8 +113,7 @@ "vscode:prepublish": "node ./node_modules/typescript/bin/tsc -p . --pretty false", "package": "node ./node_modules/@vscode/vsce/vsce package", "audit": "npm audit --audit-level=high", - "check": "node ./node_modules/typescript/bin/tsc -p . --pretty false && node --test test/*.test.js && node ./test/integration/runTest.js && node ./node_modules/@vscode/vsce/vsce ls", - "generate:icon": "node ./scripts/generate-icon.js" + "check": "node ./node_modules/typescript/bin/tsc -p . --pretty false && node --test test/*.test.js && node ./test/integration/runTest.js && node ./node_modules/@vscode/vsce/vsce ls" }, "devDependencies": { "@types/node": "^22.20.1", diff --git a/scripts/generate-icon.js b/scripts/generate-icon.js deleted file mode 100644 index d830c9e..0000000 --- a/scripts/generate-icon.js +++ /dev/null @@ -1,97 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const zlib = require('node:zlib'); - -const width = 256; -const height = 256; -const data = Buffer.alloc((width * 4 + 1) * height); - -function setPixel(x, y, r, g, b, a = 255) { - const rowOffset = y * (width * 4 + 1); - const offset = rowOffset + 1 + x * 4; - data[offset] = r; - data[offset + 1] = g; - data[offset + 2] = b; - data[offset + 3] = a; -} - -function fillRect(x1, y1, x2, y2, color) { - for (let y = y1; y < y2; y += 1) { - for (let x = x1; x < x2; x += 1) { - setPixel(x, y, color[0], color[1], color[2], color[3] ?? 255); - } - } -} - -function strokeCircle(cx, cy, radius, thickness, color, gapStart, gapEnd) { - const min = radius - thickness / 2; - const max = radius + thickness / 2; - - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const dx = x - cx; - const dy = y - cy; - const distance = Math.sqrt(dx * dx + dy * dy); - let angle = Math.atan2(dy, dx); - if (angle < 0) { - angle += Math.PI * 2; - } - - const inGap = angle > gapStart && angle < gapEnd; - if (distance >= min && distance <= max && !inGap) { - setPixel(x, y, color[0], color[1], color[2], color[3] ?? 255); - } - } - } -} - -function crc32(buffer) { - let crc = 0xffffffff; - for (const byte of buffer) { - crc ^= byte; - for (let i = 0; i < 8; i += 1) { - crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); - } - } - - return (crc ^ 0xffffffff) >>> 0; -} - -function chunk(type, payload) { - const typeBuffer = Buffer.from(type); - const length = Buffer.alloc(4); - const crc = Buffer.alloc(4); - length.writeUInt32BE(payload.length, 0); - crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, payload])), 0); - - return Buffer.concat([length, typeBuffer, payload, crc]); -} - -for (let y = 0; y < height; y += 1) { - data[y * (width * 4 + 1)] = 0; -} - -fillRect(0, 0, width, height, [11, 15, 25]); -strokeCircle(128, 128, 72, 18, [215, 255, 100], 5.55, 0.74); -fillRect(83, 116, 137, 132, [248, 250, 252]); -fillRect(119, 104, 137, 144, [248, 250, 252]); -fillRect(142, 150, 183, 166, [125, 211, 252]); - -const header = Buffer.alloc(13); -header.writeUInt32BE(width, 0); -header.writeUInt32BE(height, 4); -header[8] = 8; -header[9] = 6; -header[10] = 0; -header[11] = 0; -header[12] = 0; - -const png = Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - chunk('IHDR', header), - chunk('IDAT', zlib.deflateSync(data)), - chunk('IEND', Buffer.alloc(0)), -]); - -fs.mkdirSync(path.resolve(__dirname, '..', 'media'), { recursive: true }); -fs.writeFileSync(path.resolve(__dirname, '..', 'media', 'icon.png'), png); diff --git a/src/command-utils.ts b/src/command-utils.ts index 9d5b801..9c7c879 100644 --- a/src/command-utils.ts +++ b/src/command-utils.ts @@ -2,6 +2,7 @@ const FALLBACK_CLI_COMMAND = 'codex'; const FALLBACK_TERMINAL_NAME = 'Codex CLI'; const MAX_CAPTURED_SHELL_OUTPUT = 32 * 1024; +type DisposableLike = { dispose(): void }; type WorkspaceFolderLike = { uri: T }; type WorkspaceLike = { workspaceFolders?: readonly WorkspaceFolderLike[]; @@ -142,6 +143,76 @@ export function shouldOfferCodexInstallDocs(command: string, exitCode: number | return buildCommandNotFoundPatterns(command).some((pattern) => pattern.test(output)); } +/** Disposal scope that owns every listener and timer created by a single launch. */ +export type LaunchSession = { + readonly terminal: TTerminal; + add(disposable: DisposableLike): void; + end(): void; +}; + +/** + * Creates a disposal scope for one launcher invocation and tracks it in the shared registry. + * Launch-scoped listeners are released as soon as the launch finishes or its terminal closes, + * instead of accumulating for the whole extension lifetime. + */ +export function createLaunchSession( + terminal: TTerminal, + registry: Set>, +): LaunchSession { + const disposables = new Set(); + let ended = false; + + const session: LaunchSession = { + terminal, + add(disposable) { + if (ended) { + disposable.dispose(); + return; + } + + disposables.add(disposable); + }, + end() { + if (ended) { + return; + } + + ended = true; + registry.delete(session); + + const pending = [...disposables]; + disposables.clear(); + + for (const disposable of pending) { + disposable.dispose(); + } + }, + }; + + registry.add(session); + + return session; +} + +/** Ends every tracked session owned by the given terminal. */ +export function endLaunchSessionsForTerminal( + registry: Set>, + terminal: TTerminal, +): void { + for (const session of [...registry]) { + if (session.terminal === terminal) { + session.end(); + } + } +} + +/** Ends every tracked session, releasing all launch-scoped listeners and timers. */ +export function endAllLaunchSessions(registry: Set>): void { + for (const session of [...registry]) { + session.end(); + } +} + /** 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 141ea76..9980a9a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,9 +1,13 @@ import * as vscode from 'vscode'; import { FALLBACK_TERMINAL_NAME, + type LaunchSession, appendBoundedOutput, buildExtensionSettingsQuery, buildTerminalName, + createLaunchSession, + endAllLaunchSessions, + endLaunchSessionsForTerminal, isCodexCommand, normalizeTerminalName, resolveCliCommandSetting, @@ -13,8 +17,10 @@ import { const SETTINGS_NAMESPACE = 'codexCliLauncher'; const CODEX_DOCS_URL = 'https://learn.chatgpt.com/docs/codex/cli'; +const SHELL_INTEGRATION_TIMEOUT_MS = 3000; let terminalSequence = 1; +const launchSessions = new Set>(); function collectShellExecutionOutput(execution: vscode.TerminalShellExecution): Promise { return (async () => { @@ -41,12 +47,24 @@ async function openCodexInstallInstructions(): Promise { } function executeCommandWithOptionalShellIntegration( - terminal: vscode.Terminal, + session: LaunchSession, command: string, - context: vscode.ExtensionContext, onShellExecutionEnd?: (event: vscode.TerminalShellExecutionEndEvent, output: string) => void | Promise, ): void { + const terminal = session.terminal; let executionStarted = false; + let shellIntegrationListener: vscode.Disposable | undefined; + let fallbackHandle: ReturnType | undefined; + + const stopWaitingForShellIntegration = () => { + shellIntegrationListener?.dispose(); + shellIntegrationListener = undefined; + + if (fallbackHandle !== undefined) { + clearTimeout(fallbackHandle); + fallbackHandle = undefined; + } + }; const startExecution = (shellIntegration: vscode.TerminalShellIntegration) => { if (executionStarted) { @@ -54,33 +72,40 @@ function executeCommandWithOptionalShellIntegration( } executionStarted = true; - shellIntegrationListener.dispose(); - clearTimeout(fallbackHandle); + stopWaitingForShellIntegration(); + + if (!onShellExecutionEnd) { + shellIntegration.executeCommand(command); + session.end(); + return; + } 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; + const executionListener = vscode.window.onDidEndTerminalShellExecution(async (endEvent) => { + if (endEvent.terminal !== terminal || (execution && endEvent.execution !== execution)) { + return; + } - if (executionListener) { - context.subscriptions.push(executionListener); - } + session.end(); + const output = outputPromise ? await outputPromise : ''; + await onShellExecutionEnd(endEvent, output); + }); + session.add(executionListener); execution = shellIntegration.executeCommand(command); outputPromise = collectShellExecutionOutput(execution); }; - const shellIntegrationListener = vscode.window.onDidChangeTerminalShellIntegration((event) => { + session.add({ dispose: stopWaitingForShellIntegration }); + + if (terminal.shellIntegration) { + startExecution(terminal.shellIntegration); + return; + } + + shellIntegrationListener = vscode.window.onDidChangeTerminalShellIntegration((event) => { if (event.terminal !== terminal) { return; } @@ -88,26 +113,19 @@ function executeCommandWithOptionalShellIntegration( startExecution(event.shellIntegration); }); - const fallbackHandle = setTimeout(() => { + fallbackHandle = setTimeout(() => { + fallbackHandle = undefined; + if (terminal.shellIntegration) { startExecution(terminal.shellIntegration); return; } executionStarted = true; - shellIntegrationListener.dispose(); + stopWaitingForShellIntegration(); terminal.sendText(command, true); - }, 3000); - - if (terminal.shellIntegration) { - startExecution(terminal.shellIntegration); - return; - } - - context.subscriptions.push( - shellIntegrationListener, - { dispose: () => clearTimeout(fallbackHandle) }, - ); + session.end(); + }, SHELL_INTEGRATION_TIMEOUT_MS); } async function handleMissingCodex(): Promise { @@ -121,7 +139,7 @@ async function handleMissingCodex(): Promise { } } -function watchForMissingCodex(terminal: vscode.Terminal, cliCommand: string, context: vscode.ExtensionContext): void { +function watchForMissingCodex(session: LaunchSession, cliCommand: string): void { const onShellExecutionEnd = isCodexCommand(cliCommand) ? async (endEvent: vscode.TerminalShellExecutionEndEvent, output: string) => { if (shouldOfferCodexInstallDocs(cliCommand, endEvent.exitCode, output)) { @@ -130,12 +148,7 @@ function watchForMissingCodex(terminal: vscode.Terminal, cliCommand: string, con } : undefined; - executeCommandWithOptionalShellIntegration( - terminal, - cliCommand, - context, - onShellExecutionEnd, - ); + executeCommandWithOptionalShellIntegration(session, cliCommand, onShellExecutionEnd); } export function activate(context: vscode.ExtensionContext): void { @@ -176,7 +189,7 @@ export function activate(context: vscode.ExtensionContext): void { cwd, }); terminal.show(); - watchForMissingCodex(terminal, cliCommand, context); + watchForMissingCodex(createLaunchSession(terminal, launchSessions), cliCommand); void vscode.window.setStatusBarMessage(`Started ${terminalBaseName}`, 2500); }); @@ -184,8 +197,13 @@ export function activate(context: vscode.ExtensionContext): void { await openExtensionSettings(context); }); - context.subscriptions.push(openCliCommand, openSettingsCommand); + const terminalCloseListener = vscode.window.onDidCloseTerminal((closedTerminal) => { + endLaunchSessionsForTerminal(launchSessions, closedTerminal); + }); + + context.subscriptions.push(openCliCommand, openSettingsCommand, terminalCloseListener); } export function deactivate(): void { + endAllLaunchSessions(launchSessions); } diff --git a/test/command-utils.test.js b/test/command-utils.test.js index 700bd1c..727ab4d 100644 --- a/test/command-utils.test.js +++ b/test/command-utils.test.js @@ -8,6 +8,9 @@ const { appendBoundedOutput, buildExtensionSettingsQuery, buildTerminalName, + createLaunchSession, + endAllLaunchSessions, + endLaunchSessionsForTerminal, extractExecutable, isCodexCommand, normalizeCliCommand, @@ -16,6 +19,19 @@ const { shouldOfferCodexInstallDocs, } = require('../out/command-utils.js'); +function createDisposableSpy() { + const state = { disposeCount: 0 }; + + return { + state, + disposable: { + dispose() { + state.disposeCount += 1; + }, + }, + }; +} + test('defaults target Codex CLI', () => { assert.equal(FALLBACK_CLI_COMMAND, 'codex'); assert.equal(FALLBACK_TERMINAL_NAME, 'Codex CLI'); @@ -102,6 +118,66 @@ test('shouldOfferCodexInstallDocs ignores generic not-found messages unrelated t assert.equal(shouldOfferCodexInstallDocs('codex', 1, 'Error: model not found'), false); }); +test('createLaunchSession releases launch-scoped disposables exactly once', () => { + const registry = new Set(); + const first = createDisposableSpy(); + const second = createDisposableSpy(); + const session = createLaunchSession('terminal-a', registry); + + session.add(first.disposable); + session.add(second.disposable); + assert.equal(registry.size, 1); + + session.end(); + session.end(); + + assert.equal(first.state.disposeCount, 1); + assert.equal(second.state.disposeCount, 1); + assert.equal(registry.size, 0); +}); + +test('createLaunchSession disposes late registrations instead of retaining them', () => { + const registry = new Set(); + const late = createDisposableSpy(); + const session = createLaunchSession('terminal-a', registry); + + session.end(); + session.add(late.disposable); + + assert.equal(late.state.disposeCount, 1); + assert.equal(registry.size, 0); +}); + +test('endLaunchSessionsForTerminal only ends sessions owned by the closed terminal', () => { + const registry = new Set(); + const closed = createDisposableSpy(); + const kept = createDisposableSpy(); + + createLaunchSession('terminal-a', registry).add(closed.disposable); + createLaunchSession('terminal-b', registry).add(kept.disposable); + + endLaunchSessionsForTerminal(registry, 'terminal-a'); + + assert.equal(closed.state.disposeCount, 1); + assert.equal(kept.state.disposeCount, 0); + assert.equal(registry.size, 1); +}); + +test('endAllLaunchSessions drains the registry so launches cannot accumulate', () => { + const registry = new Set(); + const first = createDisposableSpy(); + const second = createDisposableSpy(); + + createLaunchSession('terminal-a', registry).add(first.disposable); + createLaunchSession('terminal-b', registry).add(second.disposable); + + endAllLaunchSessions(registry); + + assert.equal(first.state.disposeCount, 1); + assert.equal(second.state.disposeCount, 1); + assert.equal(registry.size, 0); +}); + test('resolveTerminalCwd uses the active editor workspace when available', () => { const workspace = { workspaceFolders: [ diff --git a/test/extension-lifecycle.test.js b/test/extension-lifecycle.test.js new file mode 100644 index 0000000..299cd71 --- /dev/null +++ b/test/extension-lifecycle.test.js @@ -0,0 +1,34 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const rootDir = path.resolve(__dirname, '..'); +const extensionSource = fs.readFileSync(path.join(rootDir, 'src', 'extension.ts'), 'utf8'); + +function countOccurrences(source, pattern) { + return source.match(pattern)?.length ?? 0; +} + +test('only activation-scoped registrations reach context.subscriptions', () => { + assert.equal(countOccurrences(extensionSource, /context\.subscriptions/g), 1); + assert.match( + extensionSource, + /context\.subscriptions\.push\(openCliCommand, openSettingsCommand, terminalCloseListener\);/, + ); +}); + +test('every launch owns a disposal scope that is released when its terminal closes', () => { + assert.match(extensionSource, /createLaunchSession\(terminal, launchSessions\)/); + assert.match(extensionSource, /vscode\.window\.onDidCloseTerminal\(/); + assert.match(extensionSource, /endLaunchSessionsForTerminal\(launchSessions, closedTerminal\)/); + assert.match(extensionSource, /export function deactivate\(\): void \{\s*endAllLaunchSessions\(launchSessions\);/); +}); + +test('shell output is only collected on the missing-Codex detection path', () => { + assert.equal(countOccurrences(extensionSource, /collectShellExecutionOutput\(/g), 2); + assert.match( + extensionSource, + /if \(!onShellExecutionEnd\) \{\s*shellIntegration\.executeCommand\(command\);\s*session\.end\(\);\s*return;\s*\}/, + ); +}); diff --git a/test/metadata.test.js b/test/metadata.test.js index aa93d7d..d1fb6d6 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -41,7 +41,7 @@ test('package metadata is public-ready and clearly unofficial', () => { assert.equal(packageJson.displayName, 'Codex CLI Launcher — Run OpenAI Codex in a Side Terminal'); assert.equal(packageJson.description, 'Launch the OpenAI Codex AI coding agent in a side terminal from your editor toolbar. Unofficial; works in VS Code, Cursor & Windsurf on Windows, macOS & Linux.'); assert.equal(packageJson.publisher, 'mikesoft'); - assert.equal(packageJson.version, '0.1.8'); + assert.equal(packageJson.version, '0.1.9'); assert.equal(packageJson.icon, 'media/icon.png'); assert.equal(packageJson.license, 'MIT'); assert.equal(packageJson.repository.url, 'https://github.com/TheStreamCode/codex-cli-launcher.git'); @@ -129,6 +129,13 @@ test('package scripts use deterministic local tooling entry points', () => { assert.equal(packageJson.devDependencies['@types/vscode'], '1.103.0'); }); +test('no script can overwrite the published launcher artwork', () => { + const packageJson = readPackageJson(); + + assert.equal(packageJson.scripts['generate:icon'], undefined); + assert.equal(fs.existsSync(path.join(rootDir, 'scripts', 'generate-icon.js')), false); +}); + test('ignore rules keep generated, local, and engineering-only files out of artifacts', () => { const gitignoreEntries = readIgnoreEntries('.gitignore'); const vscodeignoreEntries = readIgnoreEntries('.vscodeignore'); @@ -187,6 +194,8 @@ test('changelog documents the initial release scope', () => { const changelog = readText('CHANGELOG.md'); assert.match(changelog, /^# Changelog$/m); + assert.match(changelog, /## 0\.1\.9/); + assert.match(changelog, /launch-scoped listeners/); assert.match(changelog, /## 0\.1\.8/); assert.match(changelog, /Bounded terminal shell-output capture/); assert.match(changelog, /## 0\.1\.7/);