feat: managed RTK integration with opt-in preference and web UI toggle (#2620)

* feat: integrate managed RTK across shell workflows

* fix(rtk): unify managed fallback and live savings wiring

* fix(rtk): improve TUI status visibility

* fix(tests): make portability tests independent of pi-coding-agent dist build

The CI portability test runs don't guarantee that
packages/pi-coding-agent has been compiled. Any test that
imported files pulling in @gsd/pi-coding-agent (resource-loader,
preferences-skills, async-bash-tool, etc.) crashed with
ERR_MODULE_NOT_FOUND pointing at dist/index.js.

Two changes to dist-redirect.mjs (the Node ESM loader hook used by
all unit tests):
- Redirect the bare @gsd/pi-coding-agent specifier to the workspace
  source entrypoint (src/index.ts) so no dist/ artifact is needed.
- Extend the load() hook to transpile *.ts files under
  packages/pi-coding-agent/src/ through TypeScript's transpileModule.
  Node's --experimental-strip-types can't handle parameter properties
  and similar syntax present in that package's source; full transpilation
  avoids the ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX crash.

Also fix the dashboard.tsx responsive grid:
- xl:grid-cols-5 → xl:grid-cols-4 2xl:grid-cols-5
  (5 metric cards no longer fit at xl without overflow; test contract
  expected xl:grid-cols-4)
- Keep loading-skeletons.tsx in sync with the same breakpoints.

Add src/tests/resolve-ts-loader.test.ts to guard the loader behaviour:
- bare @gsd/pi-coding-agent redirect points to workspace source
- direct source-entry rewrite (.js → .ts)
- transpilation removes TS parameter property syntax that strip-only
  mode cannot parse

* fix(tests): redirect all workspace package imports to source in portability tests

The previous fix only redirected @gsd/pi-coding-agent to its
source entrypoint. In CI, pi-coding-agent/src itself imports
@gsd/pi-ai (and other workspace packages) which were still pointing
at dist/. Since no workspace dist is built during the portability
test run, any transitive resolution hit the same ERR_MODULE_NOT_FOUND.

Changes to dist-redirect.mjs:
- Redirect @gsd/pi-ai, @gsd/pi-ai/oauth, @gsd/pi-agent-core, and
  @gsd/pi-tui bare imports to their workspace src/ entrypoints.
- Broaden the load() transpilation condition from
  '/packages/pi-coding-agent/src/' to '/packages/*/src/' so that
  all workspace source files are run through TypeScript's
  transpileModule, handling parameter properties and other syntax
  that Node's strip-only mode rejects.

Verified by hiding all four workspace dist/ directories locally and
running the failing test set — 96/96 pass.

* fix(tests): redirect @gsd/native sub-paths; fix Windows .cmd spawnSync

Two more portability failures after the previous fix:

1. @gsd/native sub-path imports (@gsd/native/fd, @gsd/native/text, etc.)
   were not redirected — the loader only handled the bare specifier.
   Added a prefix-match redirect for @gsd/native/* → packages/native/src/<sub>/index.ts.

2. Windows RTK tests failed because createFakeRtk produces a .cmd wrapper
   on Windows, and spawnSync(binaryPath, [...]) without shell:true silently
   returns non-zero when the binary is a .cmd file.
   Added shell: /\.(cmd|bat)$/i.test(binaryPath) to the spawnSync calls in:
   - src/resources/extensions/shared/rtk.ts (rewriteCommandWithRtk)
   - src/resources/extensions/shared/rtk-session-stats.ts (readCurrentRtkGainSummary)
   - packages/pi-coding-agent/src/utils/rtk.ts (rewriteCommandForGsd)
   Production use of rtk.exe is unaffected; the shell flag is only true for
   .cmd/.bat paths.

Verified: all 93 portability tests pass with all workspace dist/ directories
removed (simulating CI portability environment).

* fix(tests): Windows portability fixes — HOME env, managed RTK path, perf threshold

Four Windows-specific failures fixed:

1. app-smoke.test.ts: process.env.HOME is undefined on Windows (uses
   USERPROFILE instead). Changed to homedir() from node:os which works
   cross-platform.

2. Managed RTK path tests on Windows: tests placed a fake RTK as rtk.exe
   (by copying a .cmd script into a .exe filename), which Windows cannot
   execute. Two-part fix:
   - resolveRtkBinaryPath() in both rtk.ts files now falls back to rtk.cmd
     in the managed dir on Windows when rtk.exe is absent.
   - withManagedFakeRtk and equivalent patterns in rtk.test.ts,
     rtk-session-stats.test.ts, rtk-execution-seams.test.ts changed to
     place the fake at rtk.cmd instead of rtk.exe on Windows.

3. bg_shell RTK test on Windows: requires bash (for shell sessions), which
   is not available on the blacksmith-4vcpu-windows-2025 runner without
   Git Bash installed. Test now skips on win32.

4. derive-state-db perf assertion: 10ms threshold was too tight for Windows
   CI runners (measured 12ms under load). Raised to 25ms — still catches
   real regressions (baseline is 3ms locally and ~12ms on stressed runners).

* fix(tests): fix managed RTK path fallback on Windows in src/rtk.ts + fix copyable fake

Two remaining Windows failures:

1. src/rtk.ts was never patched with the rtk.cmd managed-dir fallback
   (only the shared/rtk.ts and pi-coding-agent/src/utils/rtk.ts were updated).
   Added the same rtk.cmd fallback and shell:.cmd detection to src/rtk.ts,
   which is what rtk.test.ts imports from.

2. createFakeRtk on Windows wrote '%~dp0\fake-rtk.js' in the .cmd content —
   this resolves relative to the .cmd file's own directory. When the test
   copies rtk.cmd to a different managed dir, %~dp0 resolves to the copy
   destination where fake-rtk.js does not exist. Fixed by embedding the
   absolute path to fake-rtk.js directly in the .cmd content so the fake
   works correctly regardless of where the .cmd is copied.

* feat(experimental): add RTK opt-in preference with web UI toggle

- Add `experimental` category to GSDPreferences with `rtk: boolean` (default: false)
- RTK is now opt-in: disabled by default for all projects unless explicitly enabled
- Validate experimental.* keys; unknown experimental keys produce warnings

Web UI:
- Add ExperimentalPanel component with animated toggle switch per flag
- Add /api/experimental route (GET/PATCH) to read/write flags in preferences.md
- Add 'Experimental' tab to settings dialog sidebar nav (FlaskConical icon)
- Include ExperimentalPanel at bottom of gsd-prefs mega-scroll
- Fix toggle disabled state: trigger loadSettingsData for 'experimental' section
  and self-fetch on mount when data is absent

Dashboard:
- Gate RTK Saved metric card on rtkEnabled from live auto state (web)
- Gate TUI dashboard RTK savings row on rtkEnabled
- Gate TUI footer RTK status updates on experimental.rtk preference
- Propagate rtkEnabled through AutoDashboardData → bridge-service → store

Build:
- Add scripts/build-if-stale.cjs: incremental build driver that skips each
  step (packages, root tsc, copy-resources, web) when output is newer than
  source; replaces full rebuild chain in gsd:web
- Add scripts/web-stop.cjs: robust stop with registry + legacy PID + orphan
  sweep via pgrep; handles crash/restart orphaned next-server processes
- gsd:web now uses build-if-stale.cjs (fast cold starts, instant when unchanged)
- gsd:web:stop / gsd:web:stop:all use web-stop.cjs directly

Fix: correct import path in rtk-status.ts (./preferences.js not ../preferences.js)

* fix: restore em-dash encoding in package.json to match upstream

* refactor(rtk): move command rewrite out of pi-coding-agent into GSD extension

Per review feedback from igouss: pi-coding-agent should not be modified to add
GSD-specific logic. Instead, add a proper extension point and wire RTK through it.

Changes to packages/pi-coding-agent (extension API only — no RTK logic):
- Add BashTransformEvent + BashTransformEventResult types to extension API
- Add on('bash_transform') overload to ExtensionAPI interface
- Add emitBashTransform() to ExtensionRunner (chains all handlers in order)
- Call emitBashTransform() in wrapToolWithExtensions before bash tool execution
- Export new types from extensions/index.ts and package index.ts
- Revert all RTK-specific changes from bash-executor.ts, tools/bash.ts
- Remove packages/pi-coding-agent/src/utils/rtk.ts entirely

Changes to GSD extension:
- Register bash_transform handler in register-hooks.ts that calls
  rewriteCommandWithRtk() from the existing shared/rtk.ts module
- Handler is a no-op when RTK is disabled or not installed

* fix: correct import path for shared/rtk.js in register-hooks

* fix(tests): remove deleted pi-coding-agent/utils/rtk imports from execution seams test

The RTK rewrite logic was moved out of pi-coding-agent into the GSD
extension (bash_transform hook). Tests that directly imported the
deleted utils/rtk.ts are removed; remaining tests verify the shared
RTK module and GSD-layer surfaces that still call rewriteCommandWithRtk.
This commit is contained in:
Andrew 2026-03-26 08:33:07 -07:00 committed by GitHub
parent e9aa351be9
commit 815be0a698
59 changed files with 2629 additions and 54 deletions

View file

@ -176,3 +176,67 @@ jobs:
- name: Run package tests
run: npm run test:packages
rtk-portability:
timeout-minutes: 20
needs: detect-changes
if: needs.detect-changes.outputs.docs-only != 'true'
strategy:
fail-fast: false
matrix:
include:
- label: linux
os: blacksmith-4vcpu-ubuntu-2404
- label: windows
os: blacksmith-4vcpu-windows-2025
- label: macos
os: macos-15
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
run: npm ci
- name: Validate managed RTK install
run: >-
node --experimental-strip-types --input-type=module -e
"const mod = await import('./src/rtk.ts');
const path = mod.getManagedRtkPath(process.platform);
if (!mod.validateRtkBinary(path)) {
console.error('Managed RTK validation failed:', path);
process.exit(1);
}
console.log('Managed RTK validated at', path);"
- name: Run RTK-focused portability tests
run: >-
node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs
--experimental-strip-types --experimental-test-isolation=process --test
src/tests/rtk.test.ts
src/tests/rtk-execution-seams.test.ts
src/tests/postinstall.test.ts
src/tests/app-smoke.test.ts
src/resources/extensions/gsd/tests/custom-verification.test.ts
src/resources/extensions/gsd/tests/verification-gate.test.ts
- name: Generate RTK benchmark evidence
if: matrix.label == 'linux'
run: node scripts/rtk-benchmark.mjs --output .artifacts/rtk-benchmark.md
- name: Upload RTK benchmark artifact
if: matrix.label == 'linux'
uses: actions/upload-artifact@v4
with:
name: rtk-benchmark-linux
path: .artifacts/rtk-benchmark.md

View file

@ -19,6 +19,8 @@ One command. Walk away. Come back to a built project with clean git history.
<pre><code>npm install -g gsd-pi@latest</code></pre>
> GSD now provisions a managed [RTK](https://github.com/rtk-ai/rtk) binary on supported macOS, Linux, and Windows installs to compress shell-command output in `bash`, `async_bash`, `bg_shell`, and verification flows. GSD forces `RTK_TELEMETRY_DISABLED=1` for all managed invocations. Set `GSD_RTK_DISABLED=1` to disable the integration.
> **📋 NOTICE: New to Node on Mac?** If you installed Node.js via Homebrew, you may be running a development release instead of LTS. **[Read this guide](./docs/node-lts-macos.md)** to pin Node 24 LTS and avoid compatibility issues.
</div>

View file

@ -146,6 +146,8 @@ export type {
// Events - User Bash
UserBashEvent,
UserBashEventResult,
BashTransformEvent,
BashTransformEventResult,
WidgetPlacement,
WriteToolCallEvent,
WriteToolResultEvent,

View file

@ -634,6 +634,24 @@ export class ExtensionRunner {
return result;
}
async emitBashTransform(command: string, cwd: string): Promise<string> {
if (!this.hasHandlers("bash_transform")) return command;
let current = command;
await this.invokeHandlers(
"bash_transform",
() => ({ type: "bash_transform" as const, command: current, cwd }),
(handlerResult) => {
const result = handlerResult as import("./types.js").BashTransformEventResult | undefined;
if (result?.command && result.command.trim()) {
current = result.command;
}
return { done: false }; // chain all handlers
},
);
return current;
}
async emitUserBash(event: UserBashEvent): Promise<UserBashEventResult | undefined> {
let result: UserBashEventResult | undefined;

View file

@ -607,6 +607,25 @@ export interface ModelSelectEvent {
// User Bash Events
// ============================================================================
/**
* Fired before the bash tool executes a shell command.
* Extensions can return a transformed command string.
* All registered handlers are called in order; each receives the output of the previous.
*/
export interface BashTransformEvent {
type: "bash_transform";
/** The command string about to be executed */
command: string;
/** Current working directory */
cwd: string;
}
/** Result from bash_transform event handler */
export interface BashTransformEventResult {
/** Replacement command string. If omitted or empty, the original command is used. */
command?: string;
}
/** Fired when user executes a bash command via ! or !! prefix */
export interface UserBashEvent {
type: "user_bash";
@ -846,6 +865,7 @@ export type ExtensionEvent =
| ToolExecutionUpdateEvent
| ToolExecutionEndEvent
| ModelSelectEvent
| BashTransformEvent
| UserBashEvent
| InputEvent
| ToolCallEvent
@ -1027,6 +1047,7 @@ export interface ExtensionAPI {
on(event: "tool_execution_update", handler: ExtensionHandler<ToolExecutionUpdateEvent>): void;
on(event: "tool_execution_end", handler: ExtensionHandler<ToolExecutionEndEvent>): void;
on(event: "model_select", handler: ExtensionHandler<ModelSelectEvent>): void;
on(event: "bash_transform", handler: ExtensionHandler<BashTransformEvent, BashTransformEventResult>): void;
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;

View file

@ -44,6 +44,15 @@ export function wrapToolWithExtensions<T>(tool: AgentTool<any, T>, runner: Exten
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<T>,
) => {
// For bash tool calls, let extensions transform the command before execution
if (tool.name === "bash" && runner.hasHandlers("bash_transform")) {
const input = params as { command?: string; cwd?: string };
if (typeof input.command === "string") {
const transformed = await runner.emitBashTransform(input.command, input.cwd ?? "");
params = { ...params, command: transformed };
}
}
// Emit tool_call event - extensions can block execution
if (runner.hasHandlers("tool_call")) {
try {

View file

@ -125,6 +125,8 @@ export type {
TurnStartEvent,
UserBashEvent,
UserBashEventResult,
BashTransformEvent,
BashTransformEventResult,
WidgetPlacement,
WriteToolCallEvent,
} from "./core/extensions/index.js";

View file

@ -221,8 +221,9 @@ export class FooterComponent implements Component {
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => sanitizeStatusText(text));
const statusLine = sortedStatuses.join(" ");
// Truncate to terminal width with dim ellipsis for consistency with footer style
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
// Match the rest of the footer styling: extension statuses should render
// in the same dim color as pwd/stats, with a dim ellipsis on truncation.
lines.push(truncateToWidth(theme.fg("dim", statusLine), width, theme.fg("dim", "...")));
}
return lines;

View file

@ -1,23 +1,180 @@
#!/usr/bin/env node
import { exec as execCb } from 'child_process'
import { dirname, resolve } from 'path'
import { exec as execCb, spawnSync } from 'child_process'
import { createHash, randomUUID } from 'crypto'
import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'fs'
import { arch, homedir, platform } from 'os'
import { dirname, resolve, join } from 'path'
import { Readable } from 'stream'
import { finished } from 'stream/promises'
import extractZip from 'extract-zip'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const cwd = resolve(__dirname, '..')
const shouldSkip =
const PLAYWRIGHT_SKIP =
process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1' ||
process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === 'true'
const RTK_SKIP =
process.env.GSD_SKIP_RTK_INSTALL === '1' ||
process.env.GSD_SKIP_RTK_INSTALL === 'true' ||
process.env.GSD_RTK_DISABLED === '1' ||
process.env.GSD_RTK_DISABLED === 'true'
const RTK_VERSION = '0.33.1'
const RTK_REPO = 'rtk-ai/rtk'
const RTK_ENV = { ...process.env, RTK_TELEMETRY_DISABLED: '1' }
const managedBinDir = join(process.env.GSD_HOME || join(homedir(), '.gsd'), 'agent', 'bin')
const managedBinaryPath = join(managedBinDir, platform() === 'win32' ? 'rtk.exe' : 'rtk')
function run(cmd) {
return new Promise((resolve) => {
return new Promise((resolvePromise) => {
execCb(cmd, { cwd }, (error, stdout, stderr) => {
resolve({ ok: !error, stdout, stderr })
resolvePromise({ ok: !error, stdout, stderr })
})
})
}
if (!shouldSkip) {
function logWarn(message) {
process.stderr.write(`[gsd] postinstall: ${message}\n`)
}
function resolveAssetName() {
const currentPlatform = platform()
const currentArch = arch()
if (currentPlatform === 'darwin' && currentArch === 'arm64') return 'rtk-aarch64-apple-darwin.tar.gz'
if (currentPlatform === 'darwin' && currentArch === 'x64') return 'rtk-x86_64-apple-darwin.tar.gz'
if (currentPlatform === 'linux' && currentArch === 'arm64') return 'rtk-aarch64-unknown-linux-gnu.tar.gz'
if (currentPlatform === 'linux' && currentArch === 'x64') return 'rtk-x86_64-unknown-linux-musl.tar.gz'
if (currentPlatform === 'win32' && currentArch === 'x64') return 'rtk-x86_64-pc-windows-msvc.zip'
return null
}
function parseChecksums(text) {
const checksums = new Map()
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line) continue
const match = line.match(/^([a-f0-9]{64})\s+(.+)$/i)
if (!match) continue
checksums.set(match[2], match[1].toLowerCase())
}
return checksums
}
function sha256File(path) {
const hash = createHash('sha256')
hash.update(readFileSync(path))
return hash.digest('hex')
}
async function downloadToFile(url, destination) {
const response = await fetch(url, { headers: { 'User-Agent': 'gsd-pi-postinstall' } })
if (!response.ok) {
throw new Error(`download failed (${response.status}) for ${url}`)
}
if (!response.body) {
throw new Error(`download returned no body for ${url}`)
}
const output = createWriteStream(destination)
await finished(Readable.fromWeb(response.body).pipe(output))
}
function findBinaryRecursively(rootDir, binaryName) {
const stack = [rootDir]
while (stack.length > 0) {
const current = stack.pop()
if (!current) continue
const entries = readdirSync(current, { withFileTypes: true })
for (const entry of entries) {
const fullPath = join(current, entry.name)
if (entry.isFile() && entry.name === binaryName) return fullPath
if (entry.isDirectory()) stack.push(fullPath)
}
}
return null
}
function validateRtkBinary(binaryPath) {
const result = spawnSync(binaryPath, ['rewrite', 'git status'], {
encoding: 'utf-8',
env: RTK_ENV,
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
})
return !result.error && result.status === 0 && (result.stdout || '').trim() === 'rtk git status'
}
async function ensureRtkInstalled() {
if (RTK_SKIP) return
const assetName = resolveAssetName()
if (!assetName) return
if (existsSync(managedBinaryPath) && validateRtkBinary(managedBinaryPath)) return
const tempRoot = join(managedBinDir, `.rtk-postinstall-${randomUUID().slice(0, 8)}`)
const archivePath = join(tempRoot, assetName)
const extractDir = join(tempRoot, 'extract')
const releaseBase = `https://github.com/${RTK_REPO}/releases/download/v${RTK_VERSION}`
mkdirSync(tempRoot, { recursive: true })
mkdirSync(managedBinDir, { recursive: true })
try {
const checksumsResponse = await fetch(`${releaseBase}/checksums.txt`, {
headers: { 'User-Agent': 'gsd-pi-postinstall' },
})
if (!checksumsResponse.ok) {
throw new Error(`failed to fetch RTK checksums (${checksumsResponse.status})`)
}
const checksums = parseChecksums(await checksumsResponse.text())
const expectedSha = checksums.get(assetName)
if (!expectedSha) {
throw new Error(`missing checksum for ${assetName}`)
}
await downloadToFile(`${releaseBase}/${assetName}`, archivePath)
const actualSha = sha256File(archivePath)
if (actualSha !== expectedSha) {
throw new Error(`checksum mismatch for ${assetName}`)
}
mkdirSync(extractDir, { recursive: true })
if (assetName.endsWith('.zip')) {
await extractZip(archivePath, { dir: extractDir })
} else {
const extractResult = spawnSync('tar', ['xzf', archivePath, '-C', extractDir], {
encoding: 'utf-8',
timeout: 30000,
})
if (extractResult.error || extractResult.status !== 0) {
throw new Error(extractResult.error?.message || extractResult.stderr?.trim() || `failed to extract ${assetName}`)
}
}
const extractedBinary = findBinaryRecursively(extractDir, platform() === 'win32' ? 'rtk.exe' : 'rtk')
if (!extractedBinary) {
throw new Error(`RTK binary not found in ${assetName}`)
}
copyFileSync(extractedBinary, managedBinaryPath)
if (platform() !== 'win32') {
chmodSync(managedBinaryPath, 0o755)
}
if (!validateRtkBinary(managedBinaryPath)) {
rmSync(managedBinaryPath, { force: true })
throw new Error('downloaded RTK binary failed validation')
}
} catch (error) {
logWarn(`RTK install skipped: ${error instanceof Error ? error.message : String(error)}`)
} finally {
rmSync(tempRoot, { recursive: true, force: true })
}
}
if (!PLAYWRIGHT_SKIP) {
await run('npx playwright install chromium')
}
await ensureRtkInstalled()

169
scripts/rtk-benchmark.mjs Normal file
View file

@ -0,0 +1,169 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { homedir, tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
function getManagedRtkPath() {
return join(homedir(), '.gsd', 'agent', 'bin', process.platform === 'win32' ? 'rtk.exe' : 'rtk')
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
...options,
})
if (result.error) throw result.error
return result
}
function ensureOk(result, label) {
if (result.status !== 0) {
throw new Error(`${label} failed: ${result.stderr || result.stdout || `exit ${result.status}`}`)
}
}
function createFixture(projectDir) {
mkdirSync(join(projectDir, 'src', 'components'), { recursive: true })
writeFileSync(join(projectDir, 'package.json'), JSON.stringify({
name: 'gsd-rtk-benchmark',
version: '1.0.0',
scripts: {
test: 'node test.js',
},
}, null, 2))
const testLines = []
for (let i = 0; i < 120; i += 1) {
const group = i % 6
testLines.push(`console.log('FAIL src/components/file${group}.test.ts:${i + 1}: expected value ${i}')`)
}
testLines.push('process.exit(1)')
writeFileSync(join(projectDir, 'test.js'), `${testLines.join('\n')}\n`)
for (let i = 1; i <= 80; i += 1) {
writeFileSync(
join(projectDir, 'src', 'components', `file${i}.ts`),
`export function component_${i}() {\n return "value_${i}";\n}\n`,
)
}
ensureOk(run('git', ['init', '-q'], { cwd: projectDir }), 'git init')
ensureOk(run('git', ['config', 'user.email', 'benchmark@example.com'], { cwd: projectDir }), 'git config email')
ensureOk(run('git', ['config', 'user.name', 'Benchmark'], { cwd: projectDir }), 'git config name')
ensureOk(run('git', ['add', '.'], { cwd: projectDir }), 'git add')
ensureOk(run('git', ['commit', '-qm', 'init'], { cwd: projectDir }), 'git commit')
for (let i = 1; i <= 25; i += 1) {
writeFileSync(
join(projectDir, 'src', 'components', `file${i}.ts`),
`export function component_${i}() {\n return "value_${i}";\n}\n// change ${i}\n`,
)
}
for (let i = 81; i <= 100; i += 1) {
writeFileSync(
join(projectDir, 'src', 'components', `file${i}.ts`),
`export const new_${i} = ${i}\n`,
)
}
}
function renderMarkdown({ summary, history, binaryPath }) {
const timestamp = new Date().toISOString()
return [
'# RTK benchmark evidence',
'',
`- Generated: ${timestamp}`,
`- RTK binary: \`${binaryPath}\``,
`- Telemetry: disabled via \`RTK_TELEMETRY_DISABLED=1\``,
`- Fixture: synthetic git + find + ls + npm test workload`,
'',
'## Aggregate savings',
'',
'| Commands | Input tokens | Output tokens | Saved tokens | Savings | Avg command time |',
'| --- | ---: | ---: | ---: | ---: | ---: |',
`| ${summary.total_commands} | ${summary.total_input} | ${summary.total_output} | ${summary.total_saved} | ${summary.avg_savings_pct.toFixed(1)}% | ${summary.avg_time_ms} ms |`,
'',
'## Command breakdown',
'',
'```text',
history.trim(),
'```',
'',
'## Commands exercised',
'',
'- `git status`',
'- `git diff`',
'- `find src -type f`',
'- `ls -R src`',
'- `npm run test`',
'',
].join('\n')
}
function main() {
const outputIndex = process.argv.indexOf('--output')
const outputPath = outputIndex !== -1 ? process.argv[outputIndex + 1] : null
const binaryPath = process.env.GSD_RTK_PATH || getManagedRtkPath()
if (!binaryPath) {
throw new Error('RTK binary path not resolved')
}
const workspace = mkdtempSync(join(tmpdir(), 'gsd-rtk-benchmark-'))
const homeDir = join(workspace, 'home')
const projectDir = join(workspace, 'project')
mkdirSync(homeDir, { recursive: true })
mkdirSync(projectDir, { recursive: true })
try {
createFixture(projectDir)
const env = {
...process.env,
HOME: homeDir,
RTK_TELEMETRY_DISABLED: '1',
}
const commands = [
['git', 'status'],
['git', 'diff'],
['find', 'src', '-type', 'f'],
['ls', '-R', 'src'],
['npm', 'run', 'test'],
]
for (const command of commands) {
run(binaryPath, command, { cwd: projectDir, env })
}
const summaryJson = run(binaryPath, ['gain', '--all', '--format', 'json'], { cwd: projectDir, env })
ensureOk(summaryJson, 'rtk gain --all --format json')
const historyText = run(binaryPath, ['gain', '--history'], { cwd: projectDir, env })
ensureOk(historyText, 'rtk gain --history')
const parsed = JSON.parse(summaryJson.stdout)
const markdown = renderMarkdown({
summary: parsed.summary,
history: historyText.stdout,
binaryPath,
})
if (outputPath) {
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, markdown, 'utf-8')
console.log(outputPath)
return
}
console.log(markdown)
} finally {
rmSync(workspace, { recursive: true, force: true })
}
}
main()

View file

@ -29,6 +29,8 @@ import {
import { stopWebMode } from './web-mode.js'
import { getProjectSessionsDir } from './project-sessions.js'
import { markStartup, printStartupTimings } from './startup-timings.js'
import { bootstrapRtk, GSD_RTK_DISABLED_ENV } from './rtk.js'
import { loadEffectiveGSDPreferences } from './resources/extensions/gsd/preferences.js'
// ---------------------------------------------------------------------------
// V8 compile cache — Node 22+ can cache compiled bytecode across runs,
@ -146,6 +148,28 @@ if (!process.stdin.isTTY && !isPrintMode && !hasSubcommand && !cliFlags.listMode
process.exit(1)
}
async function ensureRtkBootstrap(): Promise<void> {
if ((ensureRtkBootstrap as { _done?: boolean })._done) return
// RTK is opt-in via experimental.rtk preference. Default: disabled.
// Honor GSD_RTK_DISABLED if already explicitly set in the environment
// (env var takes precedence over preferences for manual override).
if (!process.env[GSD_RTK_DISABLED_ENV]) {
const prefs = loadEffectiveGSDPreferences();
const rtkEnabled = prefs?.preferences.experimental?.rtk === true;
if (!rtkEnabled) {
process.env[GSD_RTK_DISABLED_ENV] = "1";
}
}
const rtkStatus = await bootstrapRtk()
;(ensureRtkBootstrap as { _done?: boolean })._done = true
markStartup('bootstrapRtk')
if (!rtkStatus.available && rtkStatus.supported && rtkStatus.enabled && rtkStatus.reason) {
process.stderr.write(`[gsd] Warning: RTK unavailable — continuing without shell-command compression (${rtkStatus.reason}).\n`)
}
}
// `gsd <subcommand> --help` — show subcommand-specific help
const subcommand = cliFlags.messages[0]
if (subcommand && process.argv.includes('--help')) {
@ -198,6 +222,7 @@ if (cliFlags.messages[0] === 'web' && cliFlags.messages[1] === 'stop') {
// `gsd --web [path]` or `gsd web [start] [path]` — launch browser-only web mode
if (cliFlags.web || (cliFlags.messages[0] === 'web' && cliFlags.messages[1] !== 'stop')) {
await ensureRtkBootstrap()
const webFlags = parseWebCliArgs(process.argv)
const webBranch = await runWebCliBranch(webFlags, {
stderr: process.stderr,
@ -269,6 +294,7 @@ if (cliFlags.messages[0] === 'sessions') {
// `gsd headless` — run auto-mode without TUI
if (cliFlags.messages[0] === 'headless') {
await ensureRtkBootstrap()
const { runHeadless, parseHeadlessArgs } = await import('./headless.js')
await runHeadless(parseHeadlessArgs(process.argv))
process.exit(0)
@ -415,6 +441,7 @@ if (!settingsManager.getCollapseChangelog()) {
// Print / subagent mode — single-shot execution, no TTY required
// ---------------------------------------------------------------------------
if (isPrintMode) {
await ensureRtkBootstrap()
const sessionManager = cliFlags.noSession
? SessionManager.inMemory()
: SessionManager.create(process.cwd())
@ -542,6 +569,8 @@ if (!cliFlags.worktree && !isPrintMode) {
// Interactive mode — normal TTY session
// ---------------------------------------------------------------------------
await ensureRtkBootstrap()
// Per-directory session storage — same encoding as the upstream SDK so that
// /resume only shows sessions from the current working directory.
const cwd = process.cwd()
@ -659,3 +688,4 @@ const interactiveMode = new InteractiveMode(session)
markStartup('InteractiveMode')
printStartupTimings()
await interactiveMode.run()

View file

@ -71,6 +71,7 @@ if (firstArg === '--help' || firstArg === '-h') {
}
import { agentDir, appRoot } from './app-paths.js'
import { applyRtkProcessEnv } from './rtk.js'
import { serializeBundledExtensionPaths } from './bundled-extension-paths.js'
import { discoverExtensionEntryPaths } from './extension-discovery.js'
import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled } from './extension-registry.js'
@ -109,6 +110,10 @@ if (!existsSync(appRoot)) {
// GSD_CODING_AGENT_DIR — tells pi's getAgentDir() to return ~/.gsd/agent/ instead of ~/.gsd/agent/
process.env.GSD_CODING_AGENT_DIR = agentDir
// RTK environment — make ~/.gsd/agent/bin visible to all child-process paths,
// not just the bash tool, and force-disable RTK telemetry for GSD-managed use.
applyRtkProcessEnv(process.env)
// NODE_PATH — make gsd's own node_modules available to extensions loaded via jiti.
// Without this, extensions (e.g. browser-tools) can't resolve dependencies like
// `playwright` because jiti resolves modules from pi-coding-agent's location, not gsd's.

View file

@ -20,6 +20,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
import type { AsyncJobManager } from "./job-manager.js";
import { rewriteCommandWithRtk } from "../shared/rtk.js";
const schema = Type.Object({
command: Type.String({ description: "Bash command to execute in the background" }),
@ -114,7 +115,8 @@ function executeBashInBackground(
const safeReject = (err: unknown) => { if (!settled) { settled = true; reject(err); } };
const { shell, args } = getShellConfig();
const resolvedCommand = sanitizeCommand(command);
const rewrittenCommand = rewriteCommandWithRtk(command);
const resolvedCommand = sanitizeCommand(rewrittenCommand);
const child = spawn(shell, [...args, resolvedCommand], {
cwd,

View file

@ -4,6 +4,7 @@
import { randomUUID } from "node:crypto";
import type { BgProcess } from "./types.js";
import { rewriteCommandWithRtk } from "../shared/rtk.js";
// ── Query Shell Environment ────────────────────────────────────────────────
@ -128,9 +129,10 @@ export async function runOnSession(
const startIndex = bg.output.length;
// Write the sentinel-wrapped command to stdin
const rewrittenCommand = rewriteCommandWithRtk(command);
const wrappedCommand = [
`echo ${startMarker}`,
command,
rewrittenCommand,
`${exitVar}=$?`,
`echo ${endMarker} $${exitVar}`,
].join("\n");

View file

@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto";
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { getShellConfig, sanitizeCommand } from "@gsd/pi-coding-agent";
import { rewriteCommandWithRtk } from "../shared/rtk.js";
import type {
BgProcess,
BgProcessInfo,
@ -127,7 +128,9 @@ export function startProcess(opts: StartOptions): BgProcess {
const { shell, args: shellArgs } = getShellConfig();
// Shell sessions default to the user's shell if no command specified
const command = processType === "shell" && !opts.command ? shell : opts.command;
const command = processType === "shell" && !opts.command
? shell
: rewriteCommandWithRtk(opts.command);
const proc = spawn(shell, [...shellArgs, sanitizeCommand(command)], {
cwd: opts.cwd,
stdio: ["pipe", "pipe", "pipe"],

View file

@ -26,6 +26,11 @@ import { getActiveWorktreeName } from "./worktree-command.js";
import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
import { resolveServiceTierIcon, getEffectiveServiceTier } from "./service-tier.js";
import { parseUnitId } from "./unit-id.js";
import {
formatRtkSavingsLabel,
getRtkSessionSavings,
type RtkSessionSavings,
} from "../shared/rtk-session-stats.js";
// ─── UAT Slice Extraction ─────────────────────────────────────────────────────
@ -59,6 +64,10 @@ export interface AutoDashboardData {
profileDowngraded?: boolean;
/** Number of pending captures awaiting triage (0 if none or file missing) */
pendingCaptureCount: number;
/** RTK token savings for the current session, or null when unavailable. */
rtkSavings?: RtkSessionSavings | null;
/** Whether RTK is enabled via experimental.rtk preference. False when not opted in. */
rtkEnabled?: boolean;
/** Cross-process: another auto-mode session detected via auto.lock (PID, startedAt) */
remoteSession?: { pid: number; startedAt: string; unitType: string; unitId: string };
}
@ -476,6 +485,19 @@ export function updateProgressWidget(
let pulseBright = true;
let cachedLines: string[] | undefined;
let cachedWidth: number | undefined;
let cachedRtkLabel: string | null | undefined;
const refreshRtkLabel = (): void => {
try {
const sessionId = ctx.sessionManager.getSessionId();
const savings = sessionId ? getRtkSessionSavings(accessors.getBasePath(), sessionId) : null;
cachedRtkLabel = formatRtkSavingsLabel(savings);
} catch {
cachedRtkLabel = null;
}
};
refreshRtkLabel();
const pulseTimer = setInterval(() => {
pulseBright = !pulseBright;
@ -487,12 +509,15 @@ export function updateProgressWidget(
// task/slice completion mid-unit. Without this, the progress bar only
// updates at dispatch time, appearing frozen during long-running units.
// 15s (vs 5s) reduces synchronous file I/O on the hot path.
const progressRefreshTimer = mid ? setInterval(() => {
const progressRefreshTimer = setInterval(() => {
try {
updateSliceProgressCache(accessors.getBasePath(), mid.id, slice?.id);
if (mid) {
updateSliceProgressCache(accessors.getBasePath(), mid.id, slice?.id);
}
refreshRtkLabel();
cachedLines = undefined;
} catch { /* non-fatal */ }
}, 15_000) : null;
}, 15_000);
return {
render(width: number): string[] {
@ -776,6 +801,9 @@ export function updateProgressWidget(
if (statsLine) {
lines.push(rightAlign("", statsLine, width));
}
if (cachedRtkLabel) {
lines.push(rightAlign("", theme.fg("dim", cachedRtkLabel), width));
}
}
// PWD line with last commit info right-aligned
const lastCommit = getLastCommit(accessors.getBasePath());

View file

@ -104,6 +104,7 @@ import {
captureAvailableSkills,
resetSkillTelemetry,
} from "./skill-telemetry.js";
import { getRtkSessionSavings } from "../shared/rtk-session-stats.js";
import {
initMetrics,
resetMetrics,
@ -301,6 +302,11 @@ export { type AutoDashboardData } from "./auto-dashboard.js";
export function getAutoDashboardData(): AutoDashboardData {
const ledger = getLedger();
const totals = ledger ? getProjectTotals(ledger.units) : null;
const sessionId = s.cmdCtx?.sessionManager?.getSessionId?.() ?? null;
const rtkSavings = sessionId && s.basePath
? getRtkSessionSavings(s.basePath, sessionId)
: null;
const rtkEnabled = loadEffectiveGSDPreferences()?.preferences.experimental?.rtk === true;
// Pending capture count — lazy check, non-fatal
let pendingCaptureCount = 0;
try {
@ -323,6 +329,8 @@ export function getAutoDashboardData(): AutoDashboardData {
totalCost: totals?.cost ?? 0,
totalTokens: totals?.tokens.total ?? 0,
pendingCaptureCount,
rtkSavings,
rtkEnabled,
};
}

View file

@ -16,6 +16,8 @@ import { getAutoDashboardData, isAutoActive, isAutoPaused, markToolEnd, markTool
import { isParallelActive, shutdownParallel } from "../parallel-orchestrator.js";
import { checkToolCallLoop, resetToolCallLoopGuard } from "./tool-call-loop-guard.js";
import { saveActivityLog } from "../activity-log.js";
import { startRtkStatusUpdates, stopRtkStatusUpdates } from "../rtk-status.js";
import { rewriteCommandWithRtk } from "../../shared/rtk.js";
// Skip the welcome screen on the very first session_start — cli.ts already
// printed it before the TUI launched. Only re-print on /clear (subsequent sessions).
@ -27,10 +29,19 @@ async function syncServiceTierStatus(ctx: ExtensionContext): Promise<void> {
}
export function registerHooks(pi: ExtensionAPI): void {
// Route all agent bash tool commands through RTK rewrite when opted in.
// This is a no-op when RTK is disabled or not installed.
pi.on("bash_transform", async (event) => {
const rewritten = rewriteCommandWithRtk(event.command);
if (rewritten === event.command) return undefined;
return { command: rewritten };
});
pi.on("session_start", async (_event, ctx) => {
resetWriteGateState();
resetToolCallLoopGuard();
await syncServiceTierStatus(ctx);
startRtkStatusUpdates(ctx);
// Apply show_token_cost preference (#1515)
try {
@ -75,6 +86,11 @@ export function registerHooks(pi: ExtensionAPI): void {
clearDiscussionFlowState();
await syncServiceTierStatus(ctx);
loadToolApiKeys();
startRtkStatusUpdates(ctx);
});
pi.on("session_fork", async (_event, ctx) => {
startRtkStatusUpdates(ctx);
});
pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
@ -123,6 +139,7 @@ export function registerHooks(pi: ExtensionAPI): void {
});
pi.on("session_shutdown", async (_event, ctx: ExtensionContext) => {
stopRtkStatusUpdates(ctx);
if (isParallelActive()) {
try {
await shutdownParallel(process.cwd());

View file

@ -22,6 +22,7 @@ import { join, resolve, sep } from "node:path";
import { spawnSync } from "node:child_process";
import type { StepDefinition, VerifyPolicy } from "./definition-loader.js";
import { readFrozenDefinition } from "./custom-workflow-engine.js";
import { rewriteCommandWithRtk } from "../shared/rtk.js";
/** Verification outcome type — matches ExecutionPolicy.verify() return type. */
export type VerificationOutcome = "continue" | "retry" | "pause";
@ -164,7 +165,8 @@ function handleShellCommand(
return "pause";
}
const result = spawnSync("sh", ["-c", verify.command], {
const rewrittenCommand = rewriteCommandWithRtk(verify.command);
const result = spawnSync("sh", ["-c", rewrittenCommand], {
cwd: runDir,
timeout: 30_000,
encoding: "utf-8",

View file

@ -557,6 +557,13 @@ export class GSDDashboardOverlay {
if (cacheRate > 0) {
lines.push(row(`${th.fg("dim", "cache hit rate:")} ${th.fg("text", `${cacheRate}%`)}`));
}
if (this.dashData.rtkEnabled && this.dashData.rtkSavings && this.dashData.rtkSavings.commands > 0) {
const rtk = this.dashData.rtkSavings;
lines.push(row(
`${th.fg("dim", "rtk saved:")} ${th.fg("text", formatTokenCount(rtk.savedTokens))} ${th.fg("dim", `(${Math.round(rtk.savingsPct)}% · ${rtk.commands} cmd${rtk.commands === 1 ? "" : "s"})`)}`,
));
}
}
// Environment health section (#1221) — only show issues

View file

@ -241,6 +241,9 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
**Known unit types for `before`/`after`:** `research-milestone`, `plan-milestone`, `research-slice`, `plan-slice`, `execute-task`, `complete-slice`, `replan-slice`, `reassess-roadmap`, `run-uat`.
- `experimental`: opt-in experimental features. All features here are **off by default** — you must explicitly set each one to `true` to enable it. Features in this block may change or be removed without a deprecation cycle while in experimental status. Keys:
- `rtk`: boolean — enable RTK (Real-Time Kompression) shell-command compression. When enabled, GSD wraps shell commands through the RTK binary to reduce token usage during command execution. RTK is downloaded automatically on first use if not already installed. **Default: `false`** (opt-in required). Set `GSD_RTK_DISABLED=1` in the environment to force-disable regardless of this preference.
---
## Best Practices
@ -652,3 +655,15 @@ verification_max_retries: 2
```
Runs test, lint, and typecheck after each task. On failure, auto-fix is attempted up to 2 times before reporting the issue.
## Experimental Features Example
```yaml
---
version: 1
experimental:
rtk: true
---
```
Opts in to RTK shell-command compression. RTK is downloaded automatically on first use. Set `GSD_RTK_DISABLED=1` to force-disable at the environment level regardless of this setting.

View file

@ -93,6 +93,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"service_tier",
"forensics_dedup",
"show_token_cost",
"experimental",
]);
/** Canonical list of all dispatch unit types. */
@ -182,6 +183,20 @@ export interface CmuxPreferences {
browser?: boolean;
}
/**
* Opt-in experimental features. All features in this block are disabled by
* default and must be explicitly enabled. They may change or be removed without
* a deprecation cycle while in experimental status.
*/
export interface ExperimentalPreferences {
/**
* Enable RTK (Real-Time Kompression) shell-command compression.
* RTK wraps shell commands to reduce token usage during command execution.
* Default: false (opt-in required).
*/
rtk?: boolean;
}
export interface GSDPreferences {
version?: number;
mode?: WorkflowMode;
@ -233,6 +248,11 @@ export interface GSDPreferences {
forensics_dedup?: boolean;
/** Opt-in: show per-prompt and cumulative session token cost in the footer. Default: false. */
show_token_cost?: boolean;
/**
* Opt-in experimental features. All features here are disabled by default.
* See the preferences reference for details on each feature.
*/
experimental?: ExperimentalPreferences;
}
export interface LoadedGSDPreferences {

View file

@ -793,5 +793,31 @@ export function validatePreferences(preferences: GSDPreferences): {
}
}
// ─── Experimental Features ────────────────────────────────────────
if (preferences.experimental !== undefined) {
if (typeof preferences.experimental === "object" && preferences.experimental !== null) {
const exp = preferences.experimental as unknown as Record<string, unknown>;
const validExp: import("./preferences-types.js").ExperimentalPreferences = {};
if (exp.rtk !== undefined) {
if (typeof exp.rtk === "boolean") validExp.rtk = exp.rtk;
else errors.push("experimental.rtk must be a boolean");
}
const knownExpKeys = new Set(["rtk"]);
for (const key of Object.keys(exp)) {
if (!knownExpKeys.has(key)) {
warnings.push(`unknown experimental key "${key}" — ignored`);
}
}
if (Object.keys(validExp).length > 0) {
validated.experimental = validExp;
}
} else {
errors.push("experimental must be an object");
}
}
return { preferences: validated, errors, warnings };
}

View file

@ -353,6 +353,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
service_tier: override.service_tier ?? base.service_tier,
forensics_dedup: override.forensics_dedup ?? base.forensics_dedup,
show_token_cost: override.show_token_cost ?? base.show_token_cost,
experimental: (base.experimental || override.experimental)
? { ...(base.experimental ?? {}), ...(override.experimental ?? {}) }
: undefined,
};
}

View file

@ -0,0 +1,53 @@
import type { ExtensionContext } from "@gsd/pi-coding-agent";
import {
ensureRtkSessionBaseline,
formatRtkSavingsLabel,
getRtkSessionSavings,
} from "../shared/rtk-session-stats.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
const STATUS_KEY = "gsd-rtk";
const REFRESH_INTERVAL_MS = 30_000;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
function clearTimer(): void {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
function isRtkEnabledInPrefs(): boolean {
return loadEffectiveGSDPreferences()?.preferences.experimental?.rtk === true;
}
function updateStatus(ctx: ExtensionContext): void {
if (!ctx.hasUI) return;
if (!isRtkEnabledInPrefs()) return;
const basePath = ctx.cwd;
const sessionId = ctx.sessionManager.getSessionId();
ensureRtkSessionBaseline(basePath, sessionId);
const savings = getRtkSessionSavings(basePath, sessionId);
ctx.ui.setStatus(STATUS_KEY, formatRtkSavingsLabel(savings) ?? undefined);
}
export function startRtkStatusUpdates(ctx: ExtensionContext): void {
clearTimer();
if (!isRtkEnabledInPrefs()) {
// Ensure any previously set status is cleared (e.g. preference was toggled off)
ctx.ui.setStatus(STATUS_KEY, undefined);
return;
}
updateStatus(ctx);
if (!ctx.hasUI) return;
refreshTimer = setInterval(() => {
updateStatus(ctx);
}, REFRESH_INTERVAL_MS);
}
export function stopRtkStatusUpdates(ctx?: ExtensionContext): void {
clearTimer();
ctx?.ui.setStatus(STATUS_KEY, undefined);
}

View file

@ -71,6 +71,8 @@ remote_questions:
uat_dispatch:
post_unit_hooks: []
pre_dispatch_hooks: []
# experimental:
# rtk: false
---
# GSD Skill Preferences

View file

@ -1,5 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
unitVerb,
@ -11,6 +13,9 @@ import {
extractUatSliceId,
} from "../auto-dashboard.ts";
const autoSource = readFileSync(join(process.cwd(), "src", "resources", "extensions", "gsd", "auto.ts"), "utf-8");
const dashboardSource = readFileSync(join(process.cwd(), "src", "resources", "extensions", "gsd", "auto-dashboard.ts"), "utf-8");
// ─── unitVerb ─────────────────────────────────────────────────────────────
test("unitVerb maps known unit types to verbs", () => {
@ -180,6 +185,17 @@ test("formatAutoElapsed returns empty string for negative autoStartTime", () =>
assert.equal(formatAutoElapsed(NaN), "");
});
test("getAutoDashboardData returns RTK savings in the dashboard payload", () => {
assert.match(autoSource, /const rtkSavings = sessionId && s\.basePath/);
assert.match(autoSource, /rtkSavings,/);
});
test("auto progress widget renders RTK savings under the footer stats line", () => {
assert.match(dashboardSource, /formatRtkSavingsLabel/);
assert.match(dashboardSource, /getRtkSessionSavings\(accessors\.getBasePath\(\), sessionId\)/);
assert.match(dashboardSource, /lines\.push\(rightAlign\("", theme\.fg\("dim", cachedRtkLabel\), width\)\);/);
});
// ─── extractUatSliceId ───────────────────────────────────────────────────
test("extractUatSliceId extracts slice ID from M001/S01 format", () => {

View file

@ -15,6 +15,7 @@ import { tmpdir } from "node:os";
import { stringify } from "yaml";
import { runCustomVerification } from "../custom-verification.ts";
import type { WorkflowDefinition } from "../definition-loader.ts";
import { createFakeRtk } from "../../../../tests/rtk-test-utils.ts";
/** Create a temp run directory with the given definition and optional files. */
function makeTempRun(
@ -225,6 +226,38 @@ describe("shell-command policy", () => {
const result = runCustomVerification(runDir, "step-1");
assert.equal(result, "retry");
});
it("rewrites shell-command verification through RTK when available", () => {
const fake = createFakeRtk({
"echo raw": "echo rewritten",
});
const previous = process.env.GSD_RTK_PATH;
process.env.GSD_RTK_PATH = fake.path;
try {
const def = makeDef([
{
id: "step-1",
name: "Build artifact",
prompt: "Build the artifact",
requires: [],
produces: ["artifact.txt"],
verify: {
policy: "shell-command",
command: "echo raw",
},
},
]);
const runDir = makeTempRun(def);
const result = runCustomVerification(runDir, "step-1");
assert.equal(result, "continue");
} finally {
if (previous === undefined) delete process.env.GSD_RTK_PATH;
else process.env.GSD_RTK_PATH = previous;
fake.cleanup();
}
});
});
// ─── prompt-verify tests ────────────────────────────────────────────────

View file

@ -769,9 +769,10 @@ describe('derive-state-db', async () => {
const elapsed = performance.now() - start;
console.log(` deriveStateFromDb() took ${elapsed.toFixed(3)}ms`);
// Use 10ms threshold — catches real regressions without flaking on
// CI runners under load (1ms threshold failed at 1.050ms on GitHub Actions)
assert.ok(elapsed < 10, `perf-db: deriveStateFromDb() <10ms (got ${elapsed.toFixed(3)}ms)`);
// Use 25ms threshold — catches real regressions without flaking on
// slower CI runners (Windows agents measured at ~12ms under load;
// the 10ms threshold was too tight for those environments).
assert.ok(elapsed < 25, `perf-db: deriveStateFromDb() <25ms (got ${elapsed.toFixed(3)}ms)`);
closeDatabase();
} finally {

View file

@ -7,17 +7,28 @@ const require = createRequire(import.meta.url);
const ROOT = new URL("../../../../../", import.meta.url);
export function resolve(specifier, context, nextResolve) {
// 1. Direct redirects to dist/ for specific packages
// 1. Redirect all workspace package bare imports to source.
// CI portability runs don't build any packages/ dist artifacts, so every
// @gsd/* specifier (including transitive ones pulled in by pi-coding-agent
// source itself) must resolve to the TypeScript source entrypoint.
if (specifier === "../../packages/pi-coding-agent/src/index.js") {
specifier = new URL("packages/pi-coding-agent/dist/index.js", ROOT).href;
specifier = new URL("packages/pi-coding-agent/src/index.ts", ROOT).href;
} else if (specifier === "@gsd/pi-coding-agent") {
specifier = new URL("packages/pi-coding-agent/src/index.ts", ROOT).href;
} else if (specifier === "@gsd/pi-ai/oauth") {
specifier = new URL("packages/pi-ai/dist/utils/oauth/index.js", ROOT).href;
specifier = new URL("packages/pi-ai/src/utils/oauth/index.ts", ROOT).href;
} else if (specifier === "@gsd/pi-ai") {
specifier = new URL("packages/pi-ai/dist/index.js", ROOT).href;
specifier = new URL("packages/pi-ai/src/index.ts", ROOT).href;
} else if (specifier === "@gsd/pi-agent-core") {
specifier = new URL("packages/pi-agent-core/dist/index.js", ROOT).href;
specifier = new URL("packages/pi-agent-core/src/index.ts", ROOT).href;
} else if (specifier === "@gsd/pi-tui") {
specifier = new URL("packages/pi-tui/dist/index.js", ROOT).href;
specifier = new URL("packages/pi-tui/src/index.ts", ROOT).href;
} else if (specifier === "@gsd/native") {
specifier = new URL("packages/native/src/index.ts", ROOT).href;
} else if (specifier.startsWith("@gsd/native/")) {
// Sub-path imports like @gsd/native/fd, @gsd/native/text, etc.
const subpath = specifier.slice("@gsd/native/".length);
specifier = new URL(`packages/native/src/${subpath}/index.ts`, ROOT).href;
}
// 2. Redirect packages/*/dist/ → packages/*/src/ with .js→.ts for strip-types
// Also handles local imports — skip rewrite for dist/ paths that are real compiled artifacts.
@ -54,9 +65,15 @@ export function resolve(specifier, context, nextResolve) {
}
export function load(url, context, nextLoad) {
// Node's --experimental-strip-types handles .ts but not .tsx (which may contain JSX).
// Use TypeScript to transpile .tsx → JS with react-jsx transform, then serve as module.
if (url.endsWith('.tsx')) {
// Node's --experimental-strip-types handles plain .ts but not .tsx and not
// all TypeScript syntax used by workspace packages (parameter properties,
// decorators, etc.). Transpile all workspace package source files and .tsx
// files through TypeScript's transpileModule to avoid those crashes.
const shouldTranspileWithTypeScript =
url.endsWith('.tsx') ||
(url.endsWith('.ts') && url.includes('/packages/') && url.includes('/src/'));
if (shouldTranspileWithTypeScript) {
const ts = require('typescript');
const source = readFileSync(fileURLToPath(url), 'utf-8');
const { outputText } = ts.transpileModule(source, {
@ -66,6 +83,8 @@ export function load(url, context, nextLoad) {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ESNext,
esModuleInterop: true,
experimentalDecorators: true,
emitDecoratorMetadata: true,
},
});
return { format: 'module', source: outputText, shortCircuit: true };

View file

@ -377,3 +377,53 @@ test("unrecognized format warning is emitted at most once (#2373)", () => {
_resetParseWarningFlag();
}
});
// ── Experimental preferences ─────────────────────────────────────────────────
test("experimental.rtk: true is accepted and stored", () => {
const result = validatePreferences({ experimental: { rtk: true } });
assert.deepEqual(result.errors, []);
assert.equal(result.preferences.experimental?.rtk, true);
});
test("experimental.rtk: false is accepted and stored", () => {
const result = validatePreferences({ experimental: { rtk: false } });
assert.deepEqual(result.errors, []);
assert.equal(result.preferences.experimental?.rtk, false);
});
test("experimental.rtk: non-boolean produces error", () => {
const result = validatePreferences({ experimental: { rtk: "yes" } } as unknown as GSDPreferences);
assert.ok(result.errors.some(e => e.includes("experimental.rtk")), `expected rtk error in: ${JSON.stringify(result.errors)}`);
});
test("experimental: non-object produces error", () => {
const result = validatePreferences({ experimental: true } as unknown as GSDPreferences);
assert.ok(result.errors.some(e => e.includes("experimental must be an object")));
});
test("experimental: unknown key produces warning", () => {
const result = validatePreferences({ experimental: { rtk: true, future_flag: true } } as unknown as GSDPreferences);
assert.ok(result.warnings.some(w => w.includes("future_flag")), `expected unknown-key warning in: ${JSON.stringify(result.warnings)}`);
assert.equal(result.preferences.experimental?.rtk, true);
});
test("experimental: omitting rtk defaults to undefined (opt-in)", () => {
const result = validatePreferences({ version: 1 });
assert.equal(result.preferences.experimental, undefined);
});
test("experimental.rtk parses correctly from preferences markdown", () => {
const content = "---\nversion: 1\nexperimental:\n rtk: true\n---\n";
const prefs = parsePreferencesMarkdown(content);
assert.notEqual(prefs, null);
assert.equal(prefs!.experimental?.rtk, true);
});
test("experimental.rtk defaults to off in new project preferences", () => {
// No experimental key → feature is disabled
const content = "---\nversion: 1\n---\n";
const prefs = parsePreferencesMarkdown(content);
assert.notEqual(prefs, null);
assert.equal(prefs!.experimental?.rtk, undefined);
});

View file

@ -8,6 +8,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join, basename } from "node:path";
import type { AuditWarning, RuntimeError, VerificationCheck, VerificationResult } from "./types.js";
import { DEFAULT_COMMAND_TIMEOUT_MS } from "./constants.js";
import { rewriteCommandWithRtk } from "../shared/rtk.js";
/** Maximum bytes of stdout/stderr to retain per command (10 KB). */
const MAX_OUTPUT_BYTES = 10 * 1024;
@ -257,10 +258,11 @@ export function runVerificationGate(options: RunVerificationGateOptions): Verifi
for (const command of commands) {
const start = Date.now();
const rewrittenCommand = rewriteCommandWithRtk(command);
// Pass the command string as an argument to the shell explicitly
// to avoid Node.js DEP0190 (spawnSync with shell: true and no args).
const shellBin = process.platform === "win32" ? "cmd" : "sh";
const shellArgs = process.platform === "win32" ? ["/c", command] : ["-c", command];
const shellArgs = process.platform === "win32" ? ["/c", rewrittenCommand] : ["-c", rewrittenCommand];
const result: SpawnSyncReturns<string> = spawnSync(shellBin, shellArgs, {
cwd: options.cwd,
stdio: "pipe",

View file

@ -0,0 +1,249 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { gsdRoot } from "../gsd/paths.js";
import { formatTokenCount } from "./format-utils.js";
import { buildRtkEnv, isRtkEnabled, resolveRtkBinaryPath } from "./rtk.js";
const SESSION_BASELINES_FILE = "rtk-session-baselines.json";
const CURRENT_SUMMARY_TTL_MS = 15_000;
const CURRENT_SUMMARY_TIMEOUT_MS = 5_000;
const MAX_BASELINE_SESSIONS = 200;
export interface RtkGainSummary {
totalCommands: number;
totalInput: number;
totalOutput: number;
totalSaved: number;
avgSavingsPct: number;
totalTimeMs: number;
avgTimeMs: number;
}
export interface RtkSessionSavings {
commands: number;
inputTokens: number;
outputTokens: number;
savedTokens: number;
savingsPct: number;
totalTimeMs: number;
avgTimeMs: number;
updatedAt: string;
}
interface BaselineEntry {
summary: RtkGainSummary;
createdAt: string;
updatedAt: string;
}
interface BaselineStore {
version: 1;
sessions: Record<string, BaselineEntry>;
}
let cachedSummary: { at: number; binaryPath: string; summary: RtkGainSummary | null } | null = null;
function getRuntimeDir(basePath: string): string {
return join(gsdRoot(basePath), "runtime");
}
function getBaselinesPath(basePath: string): string {
return join(getRuntimeDir(basePath), SESSION_BASELINES_FILE);
}
function defaultStore(): BaselineStore {
return { version: 1, sessions: {} };
}
function loadBaselineStore(basePath: string): BaselineStore {
const path = getBaselinesPath(basePath);
if (!existsSync(path)) return defaultStore();
try {
const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial<BaselineStore>;
if (parsed.version !== 1 || typeof parsed.sessions !== "object" || parsed.sessions === null) {
return defaultStore();
}
return {
version: 1,
sessions: parsed.sessions as Record<string, BaselineEntry>,
};
} catch {
return defaultStore();
}
}
function saveBaselineStore(basePath: string, store: BaselineStore): void {
const runtimeDir = getRuntimeDir(basePath);
mkdirSync(runtimeDir, { recursive: true });
const entries = Object.entries(store.sessions)
.sort((left, right) => right[1].updatedAt.localeCompare(left[1].updatedAt))
.slice(0, MAX_BASELINE_SESSIONS);
const normalized: BaselineStore = {
version: 1,
sessions: Object.fromEntries(entries),
};
writeFileSync(getBaselinesPath(basePath), JSON.stringify(normalized, null, 2), "utf-8");
}
function normalizeSummary(raw: unknown): RtkGainSummary | null {
if (!raw || typeof raw !== "object") return null;
const summary = raw as Record<string, unknown>;
return {
totalCommands: Number(summary.total_commands ?? 0),
totalInput: Number(summary.total_input ?? 0),
totalOutput: Number(summary.total_output ?? 0),
totalSaved: Number(summary.total_saved ?? 0),
avgSavingsPct: Number(summary.avg_savings_pct ?? 0),
totalTimeMs: Number(summary.total_time_ms ?? 0),
avgTimeMs: Number(summary.avg_time_ms ?? 0),
};
}
export function readCurrentRtkGainSummary(env: NodeJS.ProcessEnv = process.env): RtkGainSummary | null {
if (!isRtkEnabled(env)) return null;
const binaryPath = resolveRtkBinaryPath({ env });
if (!binaryPath) return null;
if (
cachedSummary &&
cachedSummary.binaryPath === binaryPath &&
Date.now() - cachedSummary.at < CURRENT_SUMMARY_TTL_MS
) {
return cachedSummary.summary;
}
const result = spawnSync(binaryPath, ["gain", "--all", "--format", "json"], {
encoding: "utf-8",
env: buildRtkEnv(env),
stdio: ["ignore", "pipe", "ignore"],
timeout: CURRENT_SUMMARY_TIMEOUT_MS,
// .cmd/.bat wrappers (used by fake-rtk in tests) require shell:true on Windows
shell: /\.(cmd|bat)$/i.test(binaryPath),
});
if (result.error || result.status !== 0) {
cachedSummary = { at: Date.now(), binaryPath, summary: null };
return null;
}
try {
const parsed = JSON.parse(result.stdout ?? "{}") as { summary?: unknown };
const summary = normalizeSummary(parsed.summary ?? null);
cachedSummary = { at: Date.now(), binaryPath, summary };
return summary;
} catch {
cachedSummary = { at: Date.now(), binaryPath, summary: null };
return null;
}
}
function computeSavingsDelta(current: RtkGainSummary, baseline: RtkGainSummary): RtkSessionSavings {
const commands = Math.max(0, current.totalCommands - baseline.totalCommands);
const inputTokens = Math.max(0, current.totalInput - baseline.totalInput);
const outputTokens = Math.max(0, current.totalOutput - baseline.totalOutput);
const savedTokens = Math.max(0, current.totalSaved - baseline.totalSaved);
const totalTimeMs = Math.max(0, current.totalTimeMs - baseline.totalTimeMs);
const avgTimeMs = commands > 0 ? Math.round(totalTimeMs / commands) : 0;
const savingsPct = inputTokens > 0 ? (savedTokens / inputTokens) * 100 : 0;
return {
commands,
inputTokens,
outputTokens,
savedTokens,
savingsPct,
totalTimeMs,
avgTimeMs,
updatedAt: new Date().toISOString(),
};
}
export function ensureRtkSessionBaseline(
basePath: string,
sessionId: string,
env: NodeJS.ProcessEnv = process.env,
): RtkGainSummary | null {
if (!sessionId) return null;
const current = readCurrentRtkGainSummary(env);
if (!current) return null;
const store = loadBaselineStore(basePath);
const existing = store.sessions[sessionId];
if (existing) return existing.summary;
const now = new Date().toISOString();
store.sessions[sessionId] = {
summary: current,
createdAt: now,
updatedAt: now,
};
saveBaselineStore(basePath, store);
return current;
}
export function getRtkSessionSavings(
basePath: string,
sessionId: string | null | undefined,
env: NodeJS.ProcessEnv = process.env,
): RtkSessionSavings | null {
if (!sessionId) return null;
const current = readCurrentRtkGainSummary(env);
if (!current) return null;
const store = loadBaselineStore(basePath);
const existing = store.sessions[sessionId];
if (!existing) {
const now = new Date().toISOString();
store.sessions[sessionId] = {
summary: current,
createdAt: now,
updatedAt: now,
};
saveBaselineStore(basePath, store);
return computeSavingsDelta(current, current);
}
if (
current.totalCommands < existing.summary.totalCommands ||
current.totalInput < existing.summary.totalInput ||
current.totalSaved < existing.summary.totalSaved
) {
const now = new Date().toISOString();
store.sessions[sessionId] = {
summary: current,
createdAt: existing.createdAt,
updatedAt: now,
};
saveBaselineStore(basePath, store);
return computeSavingsDelta(current, current);
}
existing.updatedAt = new Date().toISOString();
saveBaselineStore(basePath, store);
return computeSavingsDelta(current, existing.summary);
}
export function clearRtkSessionBaseline(basePath: string, sessionId: string): void {
if (!sessionId) return;
const store = loadBaselineStore(basePath);
if (!(sessionId in store.sessions)) return;
delete store.sessions[sessionId];
saveBaselineStore(basePath, store);
}
export function formatRtkSavingsLabel(savings: RtkSessionSavings | null | undefined): string | null {
if (!savings) return null;
if (savings.commands <= 0) return "rtk: waiting for shell usage";
if (savings.inputTokens <= 0 && savings.outputTokens <= 0) {
return `rtk: active (${savings.commands} cmd${savings.commands === 1 ? "" : "s"})`;
}
return `rtk: ${formatTokenCount(savings.savedTokens)} saved (${Math.round(savings.savingsPct)}%)`;
}

View file

@ -0,0 +1,120 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { delimiter, join } from "node:path";
const GSD_RTK_PATH_ENV = "GSD_RTK_PATH";
const GSD_RTK_DISABLED_ENV = "GSD_RTK_DISABLED";
const RTK_TELEMETRY_DISABLED_ENV = "RTK_TELEMETRY_DISABLED";
const RTK_REWRITE_TIMEOUT_MS = 5_000;
function isTruthy(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
export function isRtkEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return !isTruthy(env[GSD_RTK_DISABLED_ENV]);
}
export function buildRtkEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return {
...env,
[RTK_TELEMETRY_DISABLED_ENV]: "1",
};
}
function getManagedRtkDir(env: NodeJS.ProcessEnv = process.env): string {
return join(env.GSD_HOME || join(homedir(), ".gsd"), "agent", "bin");
}
function getRtkBinaryName(platform: NodeJS.Platform = process.platform): string {
return platform === "win32" ? "rtk.exe" : "rtk";
}
function getPathValue(env: NodeJS.ProcessEnv): string | undefined {
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path");
return pathKey ? env[pathKey] : env.PATH;
}
function resolvePathCandidates(pathValue: string | undefined): string[] {
if (!pathValue) return [];
return pathValue
.split(delimiter)
.map((part) => part.trim())
.filter(Boolean);
}
function resolveSystemRtkPath(pathValue: string | undefined, platform: NodeJS.Platform = process.platform): string | null {
const candidates = platform === "win32"
? ["rtk.exe", "rtk.cmd", "rtk.bat", "rtk"]
: ["rtk"];
for (const dir of resolvePathCandidates(pathValue)) {
for (const candidate of candidates) {
const fullPath = join(dir, candidate);
if (existsSync(fullPath)) {
return fullPath;
}
}
}
return null;
}
export interface ResolveRtkBinaryPathOptions {
binaryPath?: string;
env?: NodeJS.ProcessEnv;
pathValue?: string;
platform?: NodeJS.Platform;
}
export function resolveRtkBinaryPath(options: ResolveRtkBinaryPathOptions = {}): string | null {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const explicitPath = options.binaryPath ?? env[GSD_RTK_PATH_ENV];
if (explicitPath && existsSync(explicitPath)) {
return explicitPath;
}
const managedDir = getManagedRtkDir(env);
const managedPath = join(managedDir, getRtkBinaryName(platform));
if (existsSync(managedPath)) {
return managedPath;
}
// On Windows, also check for rtk.cmd in the managed dir (used by test fake RTK
// and any wrapper-style installs where a .cmd launcher accompanies the binary).
if (platform === "win32") {
const managedCmd = join(managedDir, "rtk.cmd");
if (existsSync(managedCmd)) {
return managedCmd;
}
}
return resolveSystemRtkPath(options.pathValue ?? getPathValue(env), platform);
}
export function rewriteCommandWithRtk(command: string, env: NodeJS.ProcessEnv = process.env): string {
if (!command.trim()) return command;
if (!isRtkEnabled(env)) return command;
const binaryPath = resolveRtkBinaryPath({ env });
if (!binaryPath) return command;
const result = spawnSync(binaryPath, ["rewrite", command], {
encoding: "utf-8",
env: buildRtkEnv(env),
stdio: ["ignore", "pipe", "ignore"],
timeout: RTK_REWRITE_TIMEOUT_MS,
// .cmd/.bat wrappers (used by fake-rtk in tests) require shell:true on Windows
shell: /\.(cmd|bat)$/i.test(binaryPath),
});
if (result.error) return command;
if (result.status !== 0 && result.status !== 3) return command;
const rewritten = (result.stdout ?? "").trimEnd();
return rewritten || command;
}

415
src/rtk.ts Normal file
View file

@ -0,0 +1,415 @@
import { createHash, randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, chmodSync, readdirSync } from "node:fs";
import { createWriteStream } from "node:fs";
import { arch as osArch, homedir as osHomedir } from "node:os";
import { delimiter, join } from "node:path";
import { Readable } from "node:stream";
import { finished } from "node:stream/promises";
import extractZip from "extract-zip";
export const RTK_VERSION = "0.33.1";
export const GSD_RTK_DISABLED_ENV = "GSD_RTK_DISABLED";
export const GSD_SKIP_RTK_INSTALL_ENV = "GSD_SKIP_RTK_INSTALL";
export const GSD_RTK_PATH_ENV = "GSD_RTK_PATH";
export const RTK_TELEMETRY_DISABLED_ENV = "RTK_TELEMETRY_DISABLED";
const RTK_REPO = "rtk-ai/rtk";
const RTK_REWRITE_TIMEOUT_MS = 5_000;
export interface EnsureRtkOptions {
targetDir?: string;
allowDownload?: boolean;
env?: NodeJS.ProcessEnv;
pathValue?: string;
releaseVersion?: string;
log?: (message: string) => void;
}
export interface EnsureRtkResult {
enabled: boolean;
supported: boolean;
available: boolean;
source: "disabled" | "unsupported" | "managed" | "system" | "downloaded" | "missing";
binaryPath?: string;
reason?: string;
}
function isTruthy(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
export function isRtkEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return !isTruthy(env[GSD_RTK_DISABLED_ENV]);
}
function resolveAppRoot(env: NodeJS.ProcessEnv = process.env): string {
return env.GSD_HOME || join(osHomedir(), ".gsd");
}
export function getManagedRtkDir(env: NodeJS.ProcessEnv = process.env): string {
return join(resolveAppRoot(env), "agent", "bin");
}
export function getRtkBinaryName(platform: NodeJS.Platform = process.platform): string {
return platform === "win32" ? "rtk.exe" : "rtk";
}
export function getManagedRtkPath(
platform: NodeJS.Platform = process.platform,
targetDir: string = getManagedRtkDir(),
): string {
return join(targetDir, getRtkBinaryName(platform));
}
export function prependPathEntry(env: NodeJS.ProcessEnv, entry: string): NodeJS.ProcessEnv {
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? (process.platform === "win32" ? "Path" : "PATH");
const currentPath = env[pathKey] ?? "";
const parts = currentPath.split(delimiter).filter(Boolean);
if (!parts.includes(entry)) {
env[pathKey] = [entry, currentPath].filter(Boolean).join(delimiter);
}
return env;
}
export function applyRtkProcessEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
prependPathEntry(env, getManagedRtkDir(env));
env[RTK_TELEMETRY_DISABLED_ENV] = "1";
return env;
}
function getPathValue(env: NodeJS.ProcessEnv): string | undefined {
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path");
return pathKey ? env[pathKey] : env.PATH;
}
export function buildRtkEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return applyRtkProcessEnv({ ...env });
}
export function resolveRtkAssetName(
platform: NodeJS.Platform,
arch: string,
version: string = RTK_VERSION,
): string | null {
void version;
if (platform === "darwin" && arch === "arm64") return "rtk-aarch64-apple-darwin.tar.gz";
if (platform === "darwin" && arch === "x64") return "rtk-x86_64-apple-darwin.tar.gz";
if (platform === "linux" && arch === "arm64") return "rtk-aarch64-unknown-linux-gnu.tar.gz";
if (platform === "linux" && arch === "x64") return "rtk-x86_64-unknown-linux-musl.tar.gz";
if (platform === "win32" && arch === "x64") return "rtk-x86_64-pc-windows-msvc.zip";
return null;
}
function getReleaseBaseUrl(version: string): string {
return `https://github.com/${RTK_REPO}/releases/download/v${version}`;
}
function getChecksumsUrl(version: string): string {
return `${getReleaseBaseUrl(version)}/checksums.txt`;
}
function buildAssetUrl(version: string, assetName: string): string {
return `${getReleaseBaseUrl(version)}/${assetName}`;
}
function parseChecksums(content: string): Map<string, string> {
const checksums = new Map<string, string>();
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const match = line.match(/^([a-f0-9]{64})\s+(.+)$/i);
if (!match) continue;
checksums.set(match[2], match[1].toLowerCase());
}
return checksums;
}
function sha256File(path: string): string {
const hash = createHash("sha256");
hash.update(readFileSync(path));
return hash.digest("hex");
}
async function downloadToFile(url: string, destination: string): Promise<void> {
const response = await fetch(url, {
headers: { "User-Agent": "gsd-pi-rtk" },
});
if (!response.ok) {
throw new Error(`download failed (${response.status}) for ${url}`);
}
if (!response.body) {
throw new Error(`download returned no body for ${url}`);
}
const output = createWriteStream(destination);
await finished(Readable.fromWeb(response.body as never).pipe(output));
}
function findBinaryRecursively(rootDir: string, binaryName: string): string | null {
const stack: string[] = [rootDir];
while (stack.length > 0) {
const current = stack.pop();
if (!current) continue;
const entries = readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(current, entry.name);
if (entry.isFile() && entry.name === binaryName) {
return fullPath;
}
if (entry.isDirectory()) {
stack.push(fullPath);
}
}
}
return null;
}
function extractArchive(assetName: string, archivePath: string, extractDir: string): void {
if (!assetName.endsWith(".tar.gz")) {
throw new Error(`unsupported RTK archive format: ${assetName}`);
}
mkdirSync(extractDir, { recursive: true });
const result = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], {
encoding: "utf-8",
timeout: 30_000,
});
if (result.error || result.status !== 0) {
throw new Error(result.error?.message ?? result.stderr?.trim() ?? `tar extraction failed for ${assetName}`);
}
}
async function extractArchiveAsync(assetName: string, archivePath: string, extractDir: string): Promise<void> {
if (assetName.endsWith(".zip")) {
mkdirSync(extractDir, { recursive: true });
await extractZip(archivePath, { dir: extractDir });
return;
}
extractArchive(assetName, archivePath, extractDir);
}
function resolvePathCandidates(pathValue: string | undefined): string[] {
if (!pathValue) return [];
return pathValue
.split(delimiter)
.map((part) => part.trim())
.filter(Boolean);
}
function resolveSystemRtkPath(pathValue: string | undefined, platform: NodeJS.Platform = process.platform): string | null {
const candidates = platform === "win32"
? ["rtk.exe", "rtk.cmd", "rtk.bat", "rtk"]
: ["rtk"];
for (const dir of resolvePathCandidates(pathValue)) {
for (const candidate of candidates) {
const fullPath = join(dir, candidate);
if (existsSync(fullPath)) {
return fullPath;
}
}
}
return null;
}
export interface ResolveRtkBinaryPathOptions {
binaryPath?: string;
env?: NodeJS.ProcessEnv;
pathValue?: string;
platform?: NodeJS.Platform;
targetDir?: string;
}
export function resolveRtkBinaryPath(options: ResolveRtkBinaryPathOptions = {}): string | null {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const explicitPath = options.binaryPath ?? env[GSD_RTK_PATH_ENV];
if (explicitPath && existsSync(explicitPath)) {
return explicitPath;
}
const managedPath = getManagedRtkPath(platform, options.targetDir ?? getManagedRtkDir(env));
if (existsSync(managedPath)) {
return managedPath;
}
// On Windows, also check for rtk.cmd in the managed dir (used by test fake RTK
// and any wrapper-style installs where a .cmd launcher accompanies the binary).
if (platform === "win32") {
const managedDir = options.targetDir ?? getManagedRtkDir(env);
const managedCmd = join(managedDir, "rtk.cmd");
if (existsSync(managedCmd)) {
return managedCmd;
}
}
return resolveSystemRtkPath(options.pathValue ?? getPathValue(env), platform);
}
export interface RewriteCommandOptions {
binaryPath?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
spawnSyncImpl?: typeof spawnSync;
}
export function rewriteCommandWithRtk(command: string, options: RewriteCommandOptions = {}): string {
if (!command.trim()) return command;
if (!isRtkEnabled(options.env ?? process.env)) return command;
const env = options.env ?? process.env;
const binaryPath = resolveRtkBinaryPath({
env,
binaryPath: options.binaryPath,
});
if (!binaryPath) return command;
const run = options.spawnSyncImpl ?? spawnSync;
const result = run(binaryPath, ["rewrite", command], {
encoding: "utf-8",
env: buildRtkEnv(options.env ?? process.env),
stdio: ["ignore", "pipe", "ignore"],
timeout: options.timeoutMs ?? RTK_REWRITE_TIMEOUT_MS,
// .cmd/.bat wrappers (used by fake-rtk in tests) require shell:true on Windows
shell: /\.(cmd|bat)$/i.test(binaryPath),
});
if (result.error) return command;
if (result.status !== 0 && result.status !== 3) return command;
const rewritten = (result.stdout ?? "").trimEnd();
return rewritten || command;
}
export interface ValidateRtkBinaryOptions {
spawnSyncImpl?: typeof spawnSync;
env?: NodeJS.ProcessEnv;
}
export function validateRtkBinary(binaryPath: string, options: ValidateRtkBinaryOptions = {}): boolean {
const run = options.spawnSyncImpl ?? spawnSync;
const result = run(binaryPath, ["rewrite", "git status"], {
encoding: "utf-8",
env: buildRtkEnv(options.env ?? process.env),
stdio: ["ignore", "pipe", "ignore"],
timeout: RTK_REWRITE_TIMEOUT_MS,
});
if (result.error) return false;
if (result.status !== 0) return false;
return (result.stdout ?? "").trim() === "rtk git status";
}
export async function ensureRtkAvailable(options: EnsureRtkOptions = {}): Promise<EnsureRtkResult> {
const env = options.env ?? process.env;
if (!isRtkEnabled(env)) {
return { enabled: false, supported: true, available: false, source: "disabled", reason: `${GSD_RTK_DISABLED_ENV} is set` };
}
if (isTruthy(env[GSD_SKIP_RTK_INSTALL_ENV])) {
const configuredPath = env[GSD_RTK_PATH_ENV];
if (configuredPath && existsSync(configuredPath)) {
return { enabled: true, supported: true, available: true, source: "managed", binaryPath: configuredPath };
}
return { enabled: true, supported: true, available: false, source: "missing", reason: `${GSD_SKIP_RTK_INSTALL_ENV} is set` };
}
const targetDir = options.targetDir ?? getManagedRtkDir(env);
const managedPath = getManagedRtkPath(process.platform, targetDir);
if (existsSync(managedPath) && validateRtkBinary(managedPath, { env })) {
return { enabled: true, supported: true, available: true, source: "managed", binaryPath: managedPath };
}
const systemPath = resolveSystemRtkPath(options.pathValue ?? getPathValue(env));
if (systemPath && validateRtkBinary(systemPath, { env })) {
return { enabled: true, supported: true, available: true, source: "system", binaryPath: systemPath };
}
const version = options.releaseVersion ?? RTK_VERSION;
const assetName = resolveRtkAssetName(process.platform, osArch(), version);
if (!assetName) {
return {
enabled: true,
supported: false,
available: false,
source: "unsupported",
reason: `RTK release asset unavailable for ${process.platform}/${osArch()}`,
};
}
if (options.allowDownload === false) {
return { enabled: true, supported: true, available: false, source: "missing", reason: "download disabled" };
}
mkdirSync(targetDir, { recursive: true });
const tempRoot = join(targetDir, `.rtk-install-${randomUUID().slice(0, 8)}`);
const archivePath = join(tempRoot, assetName);
const extractDir = join(tempRoot, "extract");
mkdirSync(tempRoot, { recursive: true });
try {
const checksumsUrl = getChecksumsUrl(version);
const checksumsResponse = await fetch(checksumsUrl, { headers: { "User-Agent": "gsd-pi-rtk" } });
if (!checksumsResponse.ok) {
throw new Error(`failed to fetch RTK checksums (${checksumsResponse.status})`);
}
const checksums = parseChecksums(await checksumsResponse.text());
const expectedSha = checksums.get(assetName);
if (!expectedSha) {
throw new Error(`missing checksum for ${assetName}`);
}
await downloadToFile(buildAssetUrl(version, assetName), archivePath);
const actualSha = sha256File(archivePath);
if (actualSha !== expectedSha) {
throw new Error(`checksum mismatch for ${assetName}`);
}
await extractArchiveAsync(assetName, archivePath, extractDir);
const extractedBinary = findBinaryRecursively(extractDir, getRtkBinaryName(process.platform));
if (!extractedBinary) {
throw new Error(`RTK binary not found in ${assetName}`);
}
copyFileSync(extractedBinary, managedPath);
if (process.platform !== "win32") {
chmodSync(managedPath, 0o755);
}
if (!validateRtkBinary(managedPath, { env })) {
rmSync(managedPath, { force: true });
throw new Error("downloaded RTK binary failed validation");
}
options.log?.(`installed RTK ${version} to ${managedPath}`);
return { enabled: true, supported: true, available: true, source: "downloaded", binaryPath: managedPath };
} catch (error) {
options.log?.(`RTK install skipped: ${error instanceof Error ? error.message : String(error)}`);
return {
enabled: true,
supported: true,
available: false,
source: "missing",
reason: error instanceof Error ? error.message : String(error),
};
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
}
export async function bootstrapRtk(options: EnsureRtkOptions = {}): Promise<EnsureRtkResult> {
const result = await ensureRtkAvailable(options);
applyRtkProcessEnv(process.env);
if (result.binaryPath) {
process.env[GSD_RTK_PATH_ENV] = result.binaryPath;
}
return result;
}

View file

@ -34,7 +34,9 @@ function assertExtensionIndexExists(agentDir: string, extensionName: string): vo
test("app-paths resolve to ~/.gsd/", async () => {
const { appRoot, agentDir, sessionsDir, authFilePath } = await import("../app-paths.ts");
const home = process.env.HOME!;
// Use homedir() — process.env.HOME is undefined on Windows (uses USERPROFILE instead)
const { homedir } = await import("node:os");
const home = homedir();
assert.equal(appRoot, join(home, ".gsd"), "appRoot is ~/.gsd/");
assert.equal(agentDir, join(home, ".gsd", "agent"), "agentDir is ~/.gsd/agent/");
@ -100,6 +102,9 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async (t) => {
assert.ok(loaderSrc.includes("GSD_BIN_PATH"), "loader sets GSD_BIN_PATH");
assert.ok(loaderSrc.includes("GSD_WORKFLOW_PATH"), "loader sets GSD_WORKFLOW_PATH");
assert.ok(loaderSrc.includes("GSD_BUNDLED_EXTENSION_PATHS"), "loader sets GSD_BUNDLED_EXTENSION_PATHS");
assert.ok(loaderSrc.includes("applyRtkProcessEnv"), "loader applies RTK environment bootstrap");
const rtkSrc = readFileSync(join(projectRoot, "src", "rtk.ts"), "utf-8");
assert.ok(rtkSrc.includes("RTK_TELEMETRY_DISABLED"), "RTK helper disables telemetry for managed sessions");
assert.ok(loaderSrc.includes("serializeBundledExtensionPaths"), "loader uses shared bundled path serializer");
assert.ok(loaderSrc.includes("join(delimiter)"), "loader uses platform delimiter for NODE_PATH");

View file

@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const footerSource = readFileSync(
join(process.cwd(), "packages", "pi-coding-agent", "src", "modes", "interactive", "components", "footer.ts"),
"utf-8",
);
test("FooterComponent dims extension status lines to match the rest of the footer", () => {
assert.match(
footerSource,
/theme\.fg\("dim", statusLine\)/,
"extension status line should be wrapped in the dim footer color",
);
});

View file

@ -9,7 +9,11 @@ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
test("postinstall respects PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD", () => {
const result = spawnSync("node", ["scripts/postinstall.js"], {
cwd: projectRoot,
env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" },
env: {
...process.env,
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1",
GSD_SKIP_RTK_INSTALL: "1",
},
encoding: "utf-8",
});

View file

@ -0,0 +1,50 @@
import test from "node:test"
import assert from "node:assert/strict"
import { load as loadWithTestLoader, resolve as resolveWithTestLoader } from "../resources/extensions/gsd/tests/dist-redirect.mjs"
const nextResolve = async (specifier: string) => ({ url: specifier })
const cases = [
["@gsd/pi-coding-agent", "../../packages/pi-coding-agent/src/index.ts"],
] as const
test("resolve-ts loader redirects pi-coding-agent bare imports to the workspace source entrypoint", async () => {
for (const [specifier, relativeTarget] of cases) {
const resolved = await resolveWithTestLoader(specifier, {}, nextResolve)
assert.equal(
resolved.url,
new URL(relativeTarget, import.meta.url).href,
`${specifier} should resolve to ${relativeTarget}`,
)
}
})
test("resolve-ts loader rewrites direct pi-coding-agent source entry import to .ts", async () => {
const resolved = await resolveWithTestLoader(
"../../packages/pi-coding-agent/src/index.js",
{},
nextResolve,
)
assert.equal(
resolved.url,
new URL("../../packages/pi-coding-agent/src/index.ts", import.meta.url).href,
)
})
test("resolve-ts loader transpiles pi-coding-agent source files that strip-only mode cannot parse", async () => {
const orchestratorUrl = new URL(
"../../packages/pi-coding-agent/src/core/compaction-orchestrator.ts",
import.meta.url,
).href
const loaded = await loadWithTestLoader(orchestratorUrl, {}, async () => {
throw new Error("expected pi-coding-agent source to be transpiled before nextLoad")
})
assert.equal(loaded.format, "module")
assert.equal(loaded.shortCircuit, true)
assert.match(loaded.source, /constructor\(_deps\)/, "transpiled constructor should be valid JavaScript")
assert.doesNotMatch(loaded.source, /private readonly _deps/, "TypeScript parameter property syntax should be removed")
})

View file

@ -0,0 +1,180 @@
import test from "node:test";
import assert from "node:assert/strict";
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { rewriteCommandWithRtk as rewriteSharedCommandWithRtk } from "../resources/extensions/shared/rtk.ts";
import { runVerificationGate } from "../resources/extensions/gsd/verification-gate.ts";
import { AsyncJobManager } from "../resources/extensions/async-jobs/job-manager.ts";
import { createAsyncBashTool } from "../resources/extensions/async-jobs/async-bash-tool.ts";
import { cleanupAll, startProcess } from "../resources/extensions/bg-shell/process-manager.ts";
import { runOnSession } from "../resources/extensions/bg-shell/interaction.ts";
import { createFakeRtk } from "./rtk-test-utils.ts";
const noopSignal = new AbortController().signal;
function withFakeRtk<T>(mapping: Record<string, string | { status?: number; stdout?: string }>, run: () => Promise<T> | T): Promise<T> | T {
const fake = createFakeRtk(mapping);
const previousPath = process.env.GSD_RTK_PATH;
const previousDisabled = process.env.GSD_RTK_DISABLED;
process.env.GSD_RTK_PATH = fake.path;
delete process.env.GSD_RTK_DISABLED;
const finalize = () => {
if (previousPath === undefined) delete process.env.GSD_RTK_PATH;
else process.env.GSD_RTK_PATH = previousPath;
if (previousDisabled === undefined) delete process.env.GSD_RTK_DISABLED;
else process.env.GSD_RTK_DISABLED = previousDisabled;
fake.cleanup();
};
try {
const result = run();
if (result && typeof (result as Promise<T>).then === "function") {
return (result as Promise<T>).finally(finalize);
}
finalize();
return result;
} catch (error) {
finalize();
throw error;
}
}
function withManagedFakeRtk<T>(mapping: Record<string, string | { status?: number; stdout?: string }>, run: (env: NodeJS.ProcessEnv, managedPath: string) => Promise<T> | T): Promise<T> | T {
const fake = createFakeRtk(mapping);
const managedHome = mkdtempSync(join(tmpdir(), "gsd-rtk-managed-home-"));
const managedDir = join(managedHome, "agent", "bin");
const managedPath = join(managedDir, process.platform === "win32" ? "rtk.cmd" : "rtk");
mkdirSync(managedDir, { recursive: true });
copyFileSync(fake.path, managedPath);
if (process.platform !== "win32") {
chmodSync(managedPath, 0o755);
}
const previousHome = process.env.GSD_HOME;
const previousPath = process.env.GSD_RTK_PATH;
const previousDisabled = process.env.GSD_RTK_DISABLED;
process.env.GSD_HOME = managedHome;
delete process.env.GSD_RTK_PATH;
delete process.env.GSD_RTK_DISABLED;
const env: NodeJS.ProcessEnv = {
...process.env,
GSD_HOME: managedHome,
};
delete env.GSD_RTK_PATH;
const finalize = () => {
if (previousHome === undefined) delete process.env.GSD_HOME;
else process.env.GSD_HOME = previousHome;
if (previousPath === undefined) delete process.env.GSD_RTK_PATH;
else process.env.GSD_RTK_PATH = previousPath;
if (previousDisabled === undefined) delete process.env.GSD_RTK_DISABLED;
else process.env.GSD_RTK_DISABLED = previousDisabled;
fake.cleanup();
rmSync(managedHome, { recursive: true, force: true });
};
try {
const result = run(env, managedPath);
if (result && typeof (result as Promise<T>).then === "function") {
return (result as Promise<T>).finally(finalize);
}
finalize();
return result;
} catch (error) {
finalize();
throw error;
}
}
// NOTE: The bash tool itself no longer does RTK rewriting directly. That's now
// handled by the bash_transform extension hook in register-hooks.ts. The seam
// tests below verify the GSD-layer surfaces that still call rewriteCommandWithRtk
// directly: shared/rtk.ts, verification-gate, async-bash, and bg-shell.
test("shared RTK helper rewrites commands via fake RTK binary", async () => {
await withFakeRtk({ "echo raw": "echo rewritten" }, async () => {
const rewritten = rewriteSharedCommandWithRtk("echo raw");
assert.equal(rewritten, "echo rewritten");
});
});
test("shared RTK helper falls back to the managed RTK path when GSD_RTK_PATH is unset", async () => {
await withManagedFakeRtk({ "echo raw": "echo rewritten" }, async (env) => {
assert.equal(rewriteSharedCommandWithRtk("echo raw", env), "echo rewritten");
});
});
test("verification gate executes the RTK-rewritten command", async () => {
await withFakeRtk({ "echo raw": "echo rewritten" }, async () => {
const result = runVerificationGate({
basePath: process.cwd(),
unitId: "T-RTK",
cwd: process.cwd(),
preferenceCommands: ["echo raw"],
});
assert.equal(result.passed, true);
assert.equal(result.checks.length, 1);
assert.match(result.checks[0]?.stdout ?? "", /rewritten/);
});
});
test("async_bash executes the RTK-rewritten command", async () => {
await withFakeRtk({ "echo raw": "echo rewritten" }, async () => {
const manager = new AsyncJobManager();
const tool = createAsyncBashTool(() => manager, () => process.cwd());
const result = await tool.execute(
"rtk-async",
{ command: "echo raw", label: "rtk-async" },
noopSignal,
() => {},
undefined as never,
);
const text = result.content.map((entry) => entry.text ?? "").join("\n");
const jobId = text.match(/\*\*(bg_[a-f0-9]+)\*\*/)?.[1];
assert.ok(jobId, "expected async_bash to return a job id");
const job = manager.getJob(jobId!);
assert.ok(job, "job should be registered");
await job!.promise;
assert.match(job!.resultText ?? "", /rewritten/);
manager.shutdown();
});
});
test("bg_shell start and runOnSession both execute RTK-rewritten commands", async (t) => {
if (process.platform === "win32") {
t.skip("bg_shell requires bash; Windows CI runners don't have Git Bash");
return;
}
t.after(cleanupAll);
await withFakeRtk({ "echo raw": "echo rewritten" }, async () => {
const oneshot = startProcess({
command: "echo raw",
cwd: process.cwd(),
ownerSessionFile: "session-rtk",
});
await new Promise((resolve) => setTimeout(resolve, 300));
assert.match(oneshot.output.map((line) => line.line).join("\n"), /rewritten/);
const shellSession = startProcess({
command: "",
cwd: process.cwd(),
ownerSessionFile: "session-rtk-shell",
type: "shell",
});
await new Promise((resolve) => setTimeout(resolve, 300));
const result = await runOnSession(shellSession, "echo raw", 2_000);
assert.equal(result.exitCode, 0);
assert.match(result.output, /rewritten/);
});
});

View file

@ -0,0 +1,189 @@
import test from "node:test";
import assert from "node:assert/strict";
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
clearRtkSessionBaseline,
ensureRtkSessionBaseline,
formatRtkSavingsLabel,
getRtkSessionSavings,
} from "../resources/extensions/shared/rtk-session-stats.ts";
import { createFakeRtk } from "./rtk-test-utils.ts";
function summary(totalCommands: number, totalInput: number, totalOutput: number, totalSaved: number, totalTimeMs = 1000) {
return JSON.stringify({
summary: {
total_commands: totalCommands,
total_input: totalInput,
total_output: totalOutput,
total_saved: totalSaved,
avg_savings_pct: totalInput > 0 ? (totalSaved / totalInput) * 100 : 0,
total_time_ms: totalTimeMs,
avg_time_ms: totalCommands > 0 ? totalTimeMs / totalCommands : 0,
},
});
}
test("RTK session savings diff from a persisted baseline", () => {
const basePath = mkdtempSync(join(tmpdir(), "gsd-rtk-session-stats-"));
mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true });
const first = createFakeRtk({
"gain --all --format json": { stdout: summary(10, 1000, 600, 400) },
});
const second = createFakeRtk({
"gain --all --format json": { stdout: summary(14, 1600, 900, 700, 1800) },
});
const previous = process.env.GSD_RTK_PATH;
try {
process.env.GSD_RTK_PATH = first.path;
ensureRtkSessionBaseline(basePath, "sess-1");
process.env.GSD_RTK_PATH = second.path;
const savings = getRtkSessionSavings(basePath, "sess-1");
assert.ok(savings, "expected RTK savings snapshot");
assert.equal(savings?.commands, 4);
assert.equal(savings?.inputTokens, 600);
assert.equal(savings?.outputTokens, 300);
assert.equal(savings?.savedTokens, 300);
assert.equal(Math.round(savings?.savingsPct ?? 0), 50);
} finally {
if (previous === undefined) delete process.env.GSD_RTK_PATH;
else process.env.GSD_RTK_PATH = previous;
first.cleanup();
second.cleanup();
rmSync(basePath, { recursive: true, force: true });
}
});
test("RTK session savings baseline resets cleanly when tracking totals go backwards", () => {
const basePath = mkdtempSync(join(tmpdir(), "gsd-rtk-session-reset-"));
mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true });
const first = createFakeRtk({
"gain --all --format json": { stdout: summary(8, 800, 500, 300) },
});
const second = createFakeRtk({
"gain --all --format json": { stdout: summary(1, 100, 80, 20) },
});
const previous = process.env.GSD_RTK_PATH;
try {
process.env.GSD_RTK_PATH = first.path;
ensureRtkSessionBaseline(basePath, "sess-2");
process.env.GSD_RTK_PATH = second.path;
const savings = getRtkSessionSavings(basePath, "sess-2");
assert.ok(savings, "expected RTK savings snapshot");
assert.equal(savings?.commands, 0);
assert.equal(savings?.savedTokens, 0);
} finally {
if (previous === undefined) delete process.env.GSD_RTK_PATH;
else process.env.GSD_RTK_PATH = previous;
first.cleanup();
second.cleanup();
rmSync(basePath, { recursive: true, force: true });
}
});
test("RTK session stats fall back to the managed RTK path when GSD_RTK_PATH is unset", () => {
const basePath = mkdtempSync(join(tmpdir(), "gsd-rtk-session-managed-"));
mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true });
const fake = createFakeRtk({
"gain --all --format json": { stdout: summary(6, 900, 500, 400) },
});
const managedHome = mkdtempSync(join(tmpdir(), "gsd-rtk-home-"));
const managedDir = join(managedHome, "agent", "bin");
const managedPath = join(managedDir, process.platform === "win32" ? "rtk.cmd" : "rtk");
mkdirSync(managedDir, { recursive: true });
copyFileSync(fake.path, managedPath);
if (process.platform !== "win32") {
chmodSync(managedPath, 0o755);
}
const previousHome = process.env.GSD_HOME;
const previousPath = process.env.GSD_RTK_PATH;
try {
process.env.GSD_HOME = managedHome;
delete process.env.GSD_RTK_PATH;
const env: NodeJS.ProcessEnv = {
...process.env,
GSD_HOME: managedHome,
};
delete env.GSD_RTK_PATH;
const baseline = ensureRtkSessionBaseline(basePath, "sess-managed", env);
assert.ok(baseline, "expected baseline from managed RTK path");
const savings = getRtkSessionSavings(basePath, "sess-managed", env);
assert.ok(savings, "expected savings snapshot from managed RTK path");
assert.equal(savings?.commands, 0);
} finally {
if (previousHome === undefined) delete process.env.GSD_HOME;
else process.env.GSD_HOME = previousHome;
if (previousPath === undefined) delete process.env.GSD_RTK_PATH;
else process.env.GSD_RTK_PATH = previousPath;
fake.cleanup();
rmSync(managedHome, { recursive: true, force: true });
rmSync(basePath, { recursive: true, force: true });
}
});
test("formatRtkSavingsLabel produces a compact footer string", () => {
assert.equal(
formatRtkSavingsLabel({
commands: 5,
inputTokens: 5949,
outputTokens: 2905,
savedTokens: 3044,
savingsPct: 51.2,
totalTimeMs: 3200,
avgTimeMs: 640,
updatedAt: new Date().toISOString(),
}),
"rtk: 3.0k saved (51%)",
);
assert.equal(
formatRtkSavingsLabel({
commands: 2,
inputTokens: 0,
outputTokens: 0,
savedTokens: 0,
savingsPct: 0,
totalTimeMs: 120,
avgTimeMs: 60,
updatedAt: new Date().toISOString(),
}),
"rtk: active (2 cmds)",
);
assert.equal(formatRtkSavingsLabel(null), null);
});
test("clearRtkSessionBaseline removes a stored session entry", () => {
const basePath = mkdtempSync(join(tmpdir(), "gsd-rtk-session-clear-"));
mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true });
const fake = createFakeRtk({
"gain --all --format json": { stdout: summary(3, 300, 200, 100) },
});
const previous = process.env.GSD_RTK_PATH;
try {
process.env.GSD_RTK_PATH = fake.path;
ensureRtkSessionBaseline(basePath, "sess-clear");
clearRtkSessionBaseline(basePath, "sess-clear");
const savings = getRtkSessionSavings(basePath, "sess-clear");
assert.ok(savings, "expected savings snapshot after baseline recreation");
assert.equal(savings?.commands, 0);
} finally {
if (previous === undefined) delete process.env.GSD_RTK_PATH;
else process.env.GSD_RTK_PATH = previous;
fake.cleanup();
rmSync(basePath, { recursive: true, force: true });
}
});

View file

@ -0,0 +1,45 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
export type FakeRtkResponse = string | { status?: number; stdout?: string };
export function createFakeRtk(mapping: Record<string, FakeRtkResponse>): { path: string; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), "gsd-fake-rtk-"));
const payload = JSON.stringify(mapping);
const jsSource = `#!/usr/bin/env node
const mapping = ${payload};
const args = process.argv.slice(2);
const fullInput = args.join(' ');
const rewriteInput = args[0] === 'rewrite' ? args.slice(1).join(' ') : null;
const match = mapping[fullInput] ?? (rewriteInput !== null ? mapping[rewriteInput] : undefined);
if (match === undefined) process.exit(1);
if (typeof match === 'string') {
process.stdout.write(match);
process.exit(0);
}
if (match.stdout) process.stdout.write(match.stdout);
process.exit(match.status ?? 0);
`;
if (process.platform === "win32") {
const jsPath = join(dir, "fake-rtk.js");
const cmdPath = join(dir, "rtk.cmd");
writeFileSync(jsPath, jsSource, "utf-8");
// Use the absolute jsPath so the .cmd works even when copied to another directory.
writeFileSync(cmdPath, `@echo off\r\n"${process.execPath}" "${jsPath}" %*\r\n`, "utf-8");
return {
path: cmdPath,
cleanup: () => rmSync(dir, { recursive: true, force: true }),
};
}
const binaryPath = join(dir, "rtk");
writeFileSync(binaryPath, jsSource, "utf-8");
chmodSync(binaryPath, 0o755);
return {
path: binaryPath,
cleanup: () => rmSync(dir, { recursive: true, force: true }),
};
}

126
src/tests/rtk.test.ts Normal file
View file

@ -0,0 +1,126 @@
import test from "node:test";
import assert from "node:assert/strict";
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import {
buildRtkEnv,
ensureRtkAvailable,
GSD_RTK_DISABLED_ENV,
GSD_RTK_PATH_ENV,
GSD_SKIP_RTK_INSTALL_ENV,
getManagedRtkDir,
prependPathEntry,
resolveRtkAssetName,
resolveRtkBinaryPath,
rewriteCommandWithRtk,
validateRtkBinary,
} from "../rtk.ts";
import { createFakeRtk } from "./rtk-test-utils.ts";
test("resolveRtkAssetName maps supported release assets correctly", () => {
assert.equal(resolveRtkAssetName("darwin", "arm64"), "rtk-aarch64-apple-darwin.tar.gz");
assert.equal(resolveRtkAssetName("darwin", "x64"), "rtk-x86_64-apple-darwin.tar.gz");
assert.equal(resolveRtkAssetName("linux", "arm64"), "rtk-aarch64-unknown-linux-gnu.tar.gz");
assert.equal(resolveRtkAssetName("linux", "x64"), "rtk-x86_64-unknown-linux-musl.tar.gz");
assert.equal(resolveRtkAssetName("win32", "x64"), "rtk-x86_64-pc-windows-msvc.zip");
assert.equal(resolveRtkAssetName("win32", "arm64"), null);
});
test("prependPathEntry preserves the original PATH key casing and avoids duplicates", () => {
const env: NodeJS.ProcessEnv = { Path: "/usr/bin" };
prependPathEntry(env, "/tmp/gsd-bin");
assert.equal(env.Path, `/tmp/gsd-bin${delimiter}${"/usr/bin"}`);
prependPathEntry(env, "/tmp/gsd-bin");
assert.equal(env.Path, `/tmp/gsd-bin${delimiter}${"/usr/bin"}`);
});
test("buildRtkEnv prepends the managed bin dir and disables telemetry", () => {
const env = buildRtkEnv({ PATH: "/usr/bin" });
assert.ok(env.PATH?.startsWith(`${getManagedRtkDir()}${delimiter}`));
assert.equal(env.RTK_TELEMETRY_DISABLED, "1");
});
test("rewriteCommandWithRtk rewrites when RTK returns exit 0 or 3", () => {
const spawnSyncImpl = ((_binary: string, _args: string[]) => ({ status: 0, stdout: "rtk git status", error: undefined })) as typeof import("node:child_process").spawnSync;
assert.equal(rewriteCommandWithRtk("git status", { binaryPath: "/tmp/rtk", spawnSyncImpl }), "rtk git status");
const askSpawn = ((_binary: string, _args: string[]) => ({ status: 3, stdout: "rtk npm run test", error: undefined })) as typeof import("node:child_process").spawnSync;
assert.equal(rewriteCommandWithRtk("npm run test", { binaryPath: "/tmp/rtk", spawnSyncImpl: askSpawn }), "rtk npm run test");
});
test("rewriteCommandWithRtk passes commands through on no-match or process error", () => {
const passthroughSpawn = ((_binary: string, _args: string[]) => ({ status: 1, stdout: "", error: undefined })) as typeof import("node:child_process").spawnSync;
assert.equal(rewriteCommandWithRtk("echo hello", { binaryPath: "/tmp/rtk", spawnSyncImpl: passthroughSpawn }), "echo hello");
const failingSpawn = ((_binary: string, _args: string[]) => ({ status: null, stdout: "", error: new Error("boom") })) as typeof import("node:child_process").spawnSync;
assert.equal(rewriteCommandWithRtk("git status", { binaryPath: "/tmp/rtk", spawnSyncImpl: failingSpawn }), "git status");
});
test("rewriteCommandWithRtk respects the disable flag", () => {
const spawnSyncImpl = (() => {
throw new Error("should not be called");
}) as unknown as typeof import("node:child_process").spawnSync;
assert.equal(
rewriteCommandWithRtk("git status", {
binaryPath: "/tmp/rtk",
spawnSyncImpl,
env: { [GSD_RTK_DISABLED_ENV]: "1" },
}),
"git status",
);
});
test("rewriteCommandWithRtk falls back to the managed RTK path when GSD_RTK_PATH is unset", () => {
const fake = createFakeRtk({ "git status": "rtk git status" });
const managedHome = mkdtempSync(join(tmpdir(), "gsd-rtk-managed-home-"));
const managedDir = join(managedHome, "agent", "bin");
const managedPath = join(managedDir, process.platform === "win32" ? "rtk.cmd" : "rtk");
mkdirSync(managedDir, { recursive: true });
copyFileSync(fake.path, managedPath);
if (process.platform !== "win32") {
chmodSync(managedPath, 0o755);
}
try {
const env = {
...process.env,
GSD_HOME: managedHome,
};
delete env.GSD_RTK_PATH;
assert.equal(resolveRtkBinaryPath({ env }), managedPath);
assert.equal(rewriteCommandWithRtk("git status", { env }), "rtk git status");
} finally {
fake.cleanup();
rmSync(managedHome, { recursive: true, force: true });
}
});
test("validateRtkBinary checks the rewrite contract", () => {
const validSpawn = ((_binary: string, _args: string[]) => ({ status: 0, stdout: "rtk git status", error: undefined })) as typeof import("node:child_process").spawnSync;
assert.equal(validateRtkBinary("/tmp/rtk", { spawnSyncImpl: validSpawn }), true);
const invalidSpawn = ((_binary: string, _args: string[]) => ({ status: 0, stdout: "wrong output", error: undefined })) as typeof import("node:child_process").spawnSync;
assert.equal(validateRtkBinary("/tmp/rtk", { spawnSyncImpl: invalidSpawn }), false);
});
test("ensureRtkAvailable respects explicit disable and skip flags without downloading", async () => {
const disabled = await ensureRtkAvailable({
env: { [GSD_RTK_DISABLED_ENV]: "1" },
});
assert.equal(disabled.enabled, false);
assert.equal(disabled.source, "disabled");
const skipped = await ensureRtkAvailable({
env: {
[GSD_SKIP_RTK_INSTALL_ENV]: "1",
[GSD_RTK_PATH_ENV]: "/tmp/nonexistent-rtk",
},
});
assert.equal(skipped.available, false);
assert.equal(skipped.source, "missing");
});

View file

@ -0,0 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const dashboardPath = join(process.cwd(), "web", "components", "gsd", "dashboard.tsx");
const source = readFileSync(dashboardPath, "utf-8");
test("dashboard gates RTK Saved metric card on rtkEnabled", () => {
assert.match(source, /rtkEnabled && \(/, "dashboard should gate the RTK card on rtkEnabled");
assert.match(source, /label="RTK Saved"/, "dashboard should contain an RTK Saved card (gated)");
});
test("dashboard reads rtkEnabled from live auto state", () => {
assert.match(source, /const rtkEnabled = auto\?\.rtkEnabled === true/, "dashboard should derive rtkEnabled from the live auto payload");
});
test("dashboard reads RTK savings from live auto state", () => {
assert.match(source, /const rtkSavings = auto\?\.rtkSavings \?\? null/, "dashboard should source RTK savings from the live auto payload");
assert.doesNotMatch(source, /\/api\/rtk-savings/, "dashboard should not fetch RTK savings through a dedicated API route");
});

View file

@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
const sessionsRoute = await import("../../web/app/api/terminal/sessions/route.ts");
const streamRoute = await import("../../web/app/api/terminal/stream/route.ts");
test("terminal session creation rejects disallowed commands", async () => {
const response = await sessionsRoute.POST(
new Request("http://localhost/api/terminal/sessions?project=/tmp/demo", {
method: "POST",
body: JSON.stringify({ command: "rm" }),
}),
);
assert.equal(response.status, 403);
const payload = await response.json() as { error?: string };
assert.match(payload.error ?? "", /Command not allowed/);
});
test("terminal stream rejects disallowed commands before creating a PTY session", async () => {
const response = await streamRoute.GET(
new Request("http://localhost/api/terminal/stream?id=term-1&project=/tmp/demo&command=rm"),
);
assert.equal(response.status, 403);
const payload = await response.json() as { error?: string };
assert.match(payload.error ?? "", /Command not allowed/);
});

View file

@ -4,7 +4,7 @@ import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { AutoDashboardData } from "./bridge-service.ts";
import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts"
import { resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts";
const AUTO_DASHBOARD_MAX_BUFFER = 1024 * 1024;
const TEST_AUTO_DASHBOARD_MODULE_ENV = "GSD_WEB_TEST_AUTO_DASHBOARD_MODULE";
@ -29,6 +29,8 @@ function fallbackAutoDashboardData(): AutoDashboardData {
basePath: "",
totalCost: 0,
totalTokens: 0,
rtkSavings: null,
rtkEnabled: false,
};
}
@ -52,7 +54,6 @@ export async function collectAuthoritativeAutoDashboardData(
const checkExists = options.existsSync ?? existsSync;
const resolveTsLoader = resolveTsLoaderPath(packageRoot);
// Use test override if provided; otherwise resolve via resolveSubprocessModule
const testModulePath = env[TEST_AUTO_DASHBOARD_MODULE_ENV];
const moduleResolution = testModulePath
? { modulePath: testModulePath, useCompiledJs: false }

View file

@ -397,6 +397,17 @@ function filterAndSortSessions(
return scored.map((entry) => entry.session);
}
export interface RtkSessionSavings {
commands: number;
inputTokens: number;
outputTokens: number;
savedTokens: number;
savingsPct: number;
totalTimeMs: number;
avgTimeMs: number;
updatedAt: string;
}
export interface AutoDashboardData {
active: boolean;
paused: boolean;
@ -408,6 +419,9 @@ export interface AutoDashboardData {
basePath: string;
totalCost: number;
totalTokens: number;
rtkSavings?: RtkSessionSavings | null;
/** Whether RTK is enabled via experimental.rtk preference. False when not opted in. */
rtkEnabled?: boolean;
}
export interface BridgeLastError {

View file

@ -98,6 +98,7 @@ export async function collectSettingsData(projectCwdOverride?: string): Promise<
' scope: loaded.scope,',
' path: loaded.path,',
' warnings: loaded.warnings,',
' experimental: p.experimental ? { rtk: p.experimental.rtk } : undefined,',
' };',
'}',

View file

@ -0,0 +1,110 @@
import { homedir } from "node:os"
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
import { join, dirname } from "node:path"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
const NO_STORE = { "Cache-Control": "no-store" } as const
// ─── Helpers (same pattern as remote-questions/route.ts) ─────────────────────
function getPreferencesPath(): string {
return join(homedir(), ".gsd", "preferences.md")
}
function parseFrontmatter(content: string): { data: Record<string, unknown>; body: string } {
const startMarker = content.startsWith("---\r\n") ? "---\r\n" : "---\n"
if (!content.startsWith(startMarker)) return { data: {}, body: content }
const searchStart = startMarker.length
const endIdx = content.indexOf("\n---", searchStart)
if (endIdx === -1) return { data: {}, body: content }
const block = content.slice(searchStart, endIdx)
const afterFrontmatter = content.slice(endIdx + 4)
try {
const parsed = parseYaml(block.replace(/\r/g, ""))
const data = typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {}
return { data, body: afterFrontmatter }
} catch {
return { data: {}, body: content }
}
}
function writeFrontmatter(data: Record<string, unknown>, body: string): string {
const yamlStr = stringifyYaml(data, { lineWidth: 0 }).trimEnd()
return `---\n${yamlStr}\n---${body}`
}
function readPrefs(): { data: Record<string, unknown>; body: string } {
const path = getPreferencesPath()
if (!existsSync(path)) return { data: {}, body: "\n" }
const content = readFileSync(path, "utf-8")
return parseFrontmatter(content)
}
function writePrefs(data: Record<string, unknown>, body: string): void {
const path = getPreferencesPath()
const dir = dirname(path)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(path, writeFrontmatter(data, body), "utf-8")
}
// ─── GET — read current experimental flags ───────────────────────────────────
export async function GET(): Promise<Response> {
try {
const { data } = readPrefs()
const exp = typeof data.experimental === "object" && data.experimental !== null
? (data.experimental as Record<string, unknown>)
: {}
return Response.json({ rtk: exp.rtk === true }, { headers: NO_STORE })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return Response.json({ error: message }, { status: 500, headers: NO_STORE })
}
}
// ─── PATCH — toggle an experimental flag ────────────────────────────────────
//
// Body: { flag: "rtk", enabled: boolean }
export async function PATCH(request: Request): Promise<Response> {
try {
const body = await request.json() as Record<string, unknown>
const { flag, enabled } = body
const KNOWN_FLAGS = new Set(["rtk"])
if (typeof flag !== "string" || !KNOWN_FLAGS.has(flag)) {
return Response.json(
{ error: `Unknown experimental flag "${flag}". Known flags: ${[...KNOWN_FLAGS].join(", ")}` },
{ status: 400, headers: NO_STORE },
)
}
if (typeof enabled !== "boolean") {
return Response.json(
{ error: "enabled must be a boolean" },
{ status: 400, headers: NO_STORE },
)
}
const { data, body: mdBody } = readPrefs()
// Merge into experimental block
const existing = typeof data.experimental === "object" && data.experimental !== null
? { ...(data.experimental as Record<string, unknown>) }
: {}
existing[flag] = enabled
data.experimental = existing
writePrefs(data, mdBody)
return Response.json({ [flag]: enabled }, { headers: NO_STORE })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return Response.json(
{ error: `Failed to update experimental flag: ${message}` },
{ status: 500, headers: NO_STORE },
)
}
}

View file

@ -10,6 +10,7 @@ import {
listSessions,
getOrCreateSession,
destroySession,
isAllowedTerminalCommand,
} from "../../../../lib/pty-manager";
import { requireProjectCwd } from "../../../../../src/web/bridge-service.ts";
@ -27,19 +28,6 @@ export async function GET(): Promise<Response> {
return Response.json({ sessions: listSessions() });
}
/**
* Whitelist of commands allowed to be spawned via the terminal API.
* Only known-safe executables are permitted to prevent arbitrary code execution
* if the auth layer is ever bypassed.
*/
const ALLOWED_COMMANDS = new Set([
"gsd",
process.env.SHELL || "/bin/zsh",
"/bin/bash",
"/bin/zsh",
"/bin/sh",
]);
export async function POST(request: Request): Promise<Response> {
const projectCwd = requireProjectCwd(request);
const id = `term-${getNextIndex()}`;
@ -51,7 +39,7 @@ export async function POST(request: Request): Promise<Response> {
// No body or invalid JSON — use default shell
}
if (command && !ALLOWED_COMMANDS.has(command)) {
if (command && !isAllowedTerminalCommand(command)) {
return Response.json(
{ error: `Command not allowed: ${command}` },
{ status: 403 },

View file

@ -9,6 +9,7 @@
import {
getOrCreateSession,
addListener,
isAllowedTerminalCommand,
} from "../../../../lib/pty-manager";
import { requireProjectCwd } from "../../../../../src/web/bridge-service.ts";
@ -24,6 +25,13 @@ export async function GET(request: Request): Promise<Response> {
const commandArgs = url.searchParams.getAll("arg");
const projectCwd = requireProjectCwd(request);
if (!isAllowedTerminalCommand(command)) {
return Response.json(
{ error: `Command not allowed: ${command}` },
{ status: 403 },
);
}
// Ensure the session exists
try {
getOrCreateSession(sessionId, projectCwd, command, commandArgs);

View file

@ -11,6 +11,7 @@ import {
Download,
ExternalLink,
FileText,
FlaskConical,
FolderRoot,
GitBranch,
KeyRound,
@ -56,7 +57,7 @@ import {
} from "@/lib/dev-overrides"
import { DoctorPanel, ForensicsPanel, SkillHealthPanel } from "./diagnostics-panels"
import { KnowledgeCapturesPanel } from "./knowledge-captures-panel"
import { PrefsPanel, ModelRoutingPanel, BudgetPanel, RemoteQuestionsPanel, GeneralPanel } from "./settings-panels"
import { PrefsPanel, ModelRoutingPanel, BudgetPanel, RemoteQuestionsPanel, GeneralPanel, ExperimentalPanel } from "./settings-panels"
import { DevRootSettingsSection } from "./projects-view"
import {
QuickPanel,
@ -82,7 +83,7 @@ import {
// ─── Section metadata ────────────────────────────────────────────────
const SETTINGS_SURFACE_SECTIONS = ["general", "model", "session-behavior", "recovery", "auth", "integrations", "workspace"] as const
const SETTINGS_SURFACE_SECTIONS = ["general", "model", "session-behavior", "recovery", "auth", "integrations", "workspace", "experimental"] as const
const ADMIN_SECTION: CommandSurfaceSection = "admin"
const GIT_SURFACE_SECTIONS = ["git"] as const
const SESSION_SURFACE_SECTIONS = ["resume", "name", "fork", "session", "compact"] as const
@ -125,6 +126,7 @@ function sectionLabel(section: CommandSurfaceSection): string {
compact: "Compact",
workspace: "Workspace",
integrations: "Integrations",
experimental: "Experimental",
}
return labels[section] ?? section
}
@ -149,6 +151,7 @@ function sectionIcon(section: CommandSurfaceSection) {
compact: <Archive className="h-4 w-4" />,
workspace: <FolderRoot className="h-4 w-4" />,
integrations: <Radio className="h-4 w-4" />,
experimental: <FlaskConical className="h-4 w-4" />,
}
return icons[section] ?? null
}
@ -435,7 +438,8 @@ export function CommandSurface() {
} else if (
(commandSurface.section === "gsd-prefs" ||
commandSurface.section === "gsd-mode" ||
commandSurface.section === "gsd-config") &&
commandSurface.section === "gsd-config" ||
commandSurface.section === "experimental") &&
settingsData.phase === "idle"
) {
void loadSettingsData()
@ -2053,6 +2057,7 @@ export function CommandSurface() {
const renderSection = () => {
switch (commandSurface.section) {
case "general": return <GeneralPanel />
case "experimental": return <ExperimentalPanel />
case "model": return (
<div className="space-y-8">
{renderModelSection()}
@ -2139,6 +2144,7 @@ export function CommandSurface() {
<BudgetPanel />
<RemoteQuestionsPanel />
<GeneralPanel />
<ExperimentalPanel />
</div>
)
case "gsd-mode": return <ModelRoutingPanel />

View file

@ -9,8 +9,7 @@ import {
Circle,
Play,
GitBranch,
Loader2,
Milestone,
TrendingDown,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
@ -114,12 +113,13 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {
const workspace = getLiveWorkspaceIndex(state)
const auto = getLiveAutoDashboard(state)
const bridge = boot?.bridge ?? null
const projectCwd = boot?.project.cwd ?? null
const freshness = state.live.freshness
const elapsed = auto?.elapsed ?? 0
const totalCost = auto?.totalCost ?? 0
const totalTokens = auto?.totalTokens ?? 0
const rtkSavings = auto?.rtkSavings ?? null
const rtkEnabled = auto?.rtkEnabled === true
const currentSlice = getCurrentSlice(workspace)
const doneTasks = currentSlice?.tasks.filter((t) => t.done).length ?? 0
@ -157,6 +157,13 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {
const recentLines: WorkspaceTerminalLine[] = (state.terminalLines ?? []).slice(-6)
const isConnecting = state.bootStatus === "idle" || state.bootStatus === "loading"
const rtkValue = isConnecting ? null : formatTokens(rtkSavings?.savedTokens ?? 0)
const rtkSubtext = isConnecting
? null
: rtkSavings && rtkSavings.commands > 0
? `${Math.round(rtkSavings.savingsPct)}% saved • ${rtkSavings.commands} cmd${rtkSavings.commands === 1 ? "" : "s"}`
: "Waiting for shell usage"
// ─── Project Welcome Gate ───────────────────────────────────────────
// Show welcome screen for projects that aren't initialized with GSD yet
const detection = boot?.projectDetection
@ -221,7 +228,7 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {
</div>
<div className="flex-1 overflow-y-auto p-3 md:p-6">
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 md:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<div className="rounded-md border border-border bg-card p-4" data-testid="dashboard-current-unit">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@ -262,6 +269,14 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {
value={isConnecting ? null : formatTokens(totalTokens)}
icon={<Zap className="h-5 w-5" />}
/>
{rtkEnabled && (
<MetricCard
label="RTK Saved"
value={rtkValue}
subtext={rtkSubtext}
icon={<TrendingDown className="h-5 w-5" />}
/>
)}
</div>

View file

@ -129,7 +129,7 @@ interface DashboardSkeletonProps {
export function DashboardMetricsSkeleton({ icons }: DashboardSkeletonProps) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<CurrentUnitCardSkeleton icon={icons.Activity} />
<MetricCardSkeleton label="Elapsed Time" icon={icons.Clock} />
<MetricCardSkeleton label="Total Cost" icon={icons.DollarSign} />

View file

@ -9,6 +9,7 @@ import {
DollarSign,
Eye,
EyeOff,
FlaskConical,
KeyRound,
LoaderCircle,
Radio,
@ -1052,6 +1053,165 @@ export function GeneralPanel() {
)
}
// ═══════════════════════════════════════════════════════════════════════
// EXPERIMENTAL PANEL
// ═══════════════════════════════════════════════════════════════════════
interface ExperimentalFlag {
key: string
label: string
description: string
warning?: string
}
const EXPERIMENTAL_FLAGS: ExperimentalFlag[] = [
{
key: "rtk",
label: "RTK Shell Compression",
description:
"Wraps shell commands through the RTK binary to reduce token usage during command execution. RTK is downloaded automatically on first use.",
warning: "Experimental — may change or be removed without notice.",
},
]
export function ExperimentalPanel() {
const { state, data, busy, refresh } = useSettingsData()
const prefs = data?.preferences ?? null
const [flags, setFlags] = useState<Record<string, boolean>>({})
const [saving, setSaving] = useState<Record<string, boolean>>({})
const [saveError, setSaveError] = useState<string | null>(null)
// Trigger a settings load if data hasn't been fetched yet (e.g. navigating
// directly to the Experimental tab without going through gsd-prefs first).
useEffect(() => {
if (!data && !busy && state.phase === "idle") {
refresh()
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Sync local state from loaded prefs
useEffect(() => {
if (!prefs) return
setFlags({ rtk: prefs.experimental?.rtk === true })
}, [prefs])
async function toggle(flagKey: string, next: boolean) {
setSaving((s) => ({ ...s, [flagKey]: true }))
setSaveError(null)
try {
const res = await authFetch("/api/experimental", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ flag: flagKey, enabled: next }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string }
throw new Error(body.error ?? `HTTP ${res.status}`)
}
setFlags((f) => ({ ...f, [flagKey]: next }))
// Refresh settings data so PrefsPanel reflects the change
refresh()
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err))
} finally {
setSaving((s) => ({ ...s, [flagKey]: false }))
}
}
return (
<div className="space-y-4" data-testid="settings-experimental">
<SettingsHeader
title="Experimental"
icon={<FlaskConical className="h-3.5 w-3.5" />}
subtitle="Opt-in features — may change without notice"
onRefresh={refresh}
refreshing={busy}
/>
{state.error && <SettingsError message={state.error} />}
{saveError && <SettingsError message={saveError} />}
{busy && !data && <SettingsLoading label="Loading preferences…" />}
<div className="space-y-3">
{EXPERIMENTAL_FLAGS.map((flag) => {
const enabled = flags[flag.key] ?? false
const isSaving = saving[flag.key] ?? false
return (
<div
key={flag.key}
className="rounded-lg border border-border/40 bg-card/30 px-3 py-3 space-y-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground">{flag.label}</span>
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-[10px] font-medium",
enabled
? "bg-success/10 text-success"
: "bg-muted text-muted-foreground",
)}
>
{enabled ? "on" : "off"}
</span>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{flag.description}
</p>
{flag.warning && (
<div className="flex items-center gap-1 text-[10px] text-warning">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>{flag.warning}</span>
</div>
)}
</div>
<button
onClick={() => toggle(flag.key, !enabled)}
disabled={isSaving || busy || !data}
className={cn(
"shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed",
enabled ? "bg-success" : "bg-muted-foreground/30",
)}
role="switch"
aria-checked={enabled}
aria-label={`Toggle ${flag.label}`}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-4" : "translate-x-0.5",
)}
/>
{isSaving && (
<span className="absolute inset-0 flex items-center justify-center">
<LoaderCircle className="h-3 w-3 animate-spin text-white" />
</span>
)}
</button>
</div>
</div>
)
})}
</div>
{data && (
<p className="text-[11px] text-muted-foreground">
Changes are written to{" "}
<span className="font-mono">{prefs?.path ?? "~/.gsd/preferences.md"}</span>
{" "}and take effect on the next session.
</p>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// LEGACY EXPORTS
// ═══════════════════════════════════════════════════════════════════════
// Legacy exports for backward compatibility with gsd-prefs mega-scroll
export const TerminalSizePanel = GeneralPanel
export const EditorSizePanel = () => null

View file

@ -40,6 +40,7 @@ export type CommandSurfaceSection =
| "compact"
| "workspace"
| "integrations"
| "experimental"
// GSD subcommand surfaces (S02)
| "gsd-status"
| "gsd-visualize"

View file

@ -180,6 +180,17 @@ export interface WorkspaceIndex {
validationIssues: WorkspaceValidationIssue[]
}
export interface RtkSessionSavings {
commands: number
inputTokens: number
outputTokens: number
savedTokens: number
savingsPct: number
totalTimeMs: number
avgTimeMs: number
updatedAt: string
}
export interface AutoDashboardData {
active: boolean
paused: boolean
@ -191,6 +202,9 @@ export interface AutoDashboardData {
basePath: string
totalCost: number
totalTokens: number
rtkSavings?: RtkSessionSavings | null
/** Whether RTK is enabled via experimental.rtk preference. False when not opted in. */
rtkEnabled?: boolean
}
export interface BootResumableSession {

View file

@ -119,6 +119,19 @@ interface TerminalSpawnSpec {
label: string;
}
const ALLOWED_TERMINAL_COMMANDS = new Set([
"gsd",
process.env.SHELL || "/bin/zsh",
"/bin/bash",
"/bin/zsh",
"/bin/sh",
]);
export function isAllowedTerminalCommand(command?: string): boolean {
if (!command) return true;
return ALLOWED_TERMINAL_COMMANDS.has(command);
}
function resolveTerminalSpawnSpec(cwd: string, command?: string, commandArgs: string[] = []): TerminalSpawnSpec {
if (!command) {
const shell = getDefaultShell();
@ -235,6 +248,9 @@ function loadNodePty(): LoadedNodePty {
export function getOrCreateSession(sessionId: string, projectCwd?: string, command?: string, commandArgs: string[] = []): PtySession {
ensureProcessCleanupHandlers();
if (!isAllowedTerminalCommand(command)) {
throw new Error(`Command not allowed: ${command}`);
}
const map = getSessions();
const existing = map.get(sessionId);
if (existing?.alive) return existing;

View file

@ -107,6 +107,9 @@ export interface SettingsPreferencesData {
timeoutMinutes?: number
pollIntervalSeconds?: number
}
experimental?: {
rtk?: boolean
}
scope: "global" | "project"
path: string
warnings?: string[]