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:
parent
e9aa351be9
commit
815be0a698
59 changed files with 2629 additions and 54 deletions
64
.github/workflows/ci.yml
vendored
64
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -146,6 +146,8 @@ export type {
|
|||
// Events - User Bash
|
||||
UserBashEvent,
|
||||
UserBashEventResult,
|
||||
BashTransformEvent,
|
||||
BashTransformEventResult,
|
||||
WidgetPlacement,
|
||||
WriteToolCallEvent,
|
||||
WriteToolResultEvent,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ export type {
|
|||
TurnStartEvent,
|
||||
UserBashEvent,
|
||||
UserBashEventResult,
|
||||
BashTransformEvent,
|
||||
BashTransformEventResult,
|
||||
WidgetPlacement,
|
||||
WriteToolCallEvent,
|
||||
} from "./core/extensions/index.js";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
169
scripts/rtk-benchmark.mjs
Normal 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()
|
||||
30
src/cli.ts
30
src/cli.ts
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
53
src/resources/extensions/gsd/rtk-status.ts
Normal file
53
src/resources/extensions/gsd/rtk-status.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -71,6 +71,8 @@ remote_questions:
|
|||
uat_dispatch:
|
||||
post_unit_hooks: []
|
||||
pre_dispatch_hooks: []
|
||||
# experimental:
|
||||
# rtk: false
|
||||
---
|
||||
|
||||
# GSD Skill Preferences
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
249
src/resources/extensions/shared/rtk-session-stats.ts
Normal file
249
src/resources/extensions/shared/rtk-session-stats.ts
Normal 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)}%)`;
|
||||
}
|
||||
120
src/resources/extensions/shared/rtk.ts
Normal file
120
src/resources/extensions/shared/rtk.ts
Normal 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
415
src/rtk.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
17
src/tests/footer-component.test.ts
Normal file
17
src/tests/footer-component.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
|||
50
src/tests/resolve-ts-loader.test.ts
Normal file
50
src/tests/resolve-ts-loader.test.ts
Normal 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")
|
||||
})
|
||||
180
src/tests/rtk-execution-seams.test.ts
Normal file
180
src/tests/rtk-execution-seams.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
189
src/tests/rtk-session-stats.test.ts
Normal file
189
src/tests/rtk-session-stats.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
45
src/tests/rtk-test-utils.ts
Normal file
45
src/tests/rtk-test-utils.ts
Normal 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
126
src/tests/rtk.test.ts
Normal 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");
|
||||
});
|
||||
21
src/tests/web-dashboard-rtk-contract.test.ts
Normal file
21
src/tests/web-dashboard-rtk-contract.test.ts
Normal 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");
|
||||
});
|
||||
28
src/tests/web-terminal-allowlist.test.ts
Normal file
28
src/tests/web-terminal-allowlist.test.ts
Normal 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/);
|
||||
});
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,',
|
||||
' };',
|
||||
'}',
|
||||
|
||||
|
|
|
|||
110
web/app/api/experimental/route.ts
Normal file
110
web/app/api/experimental/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export type CommandSurfaceSection =
|
|||
| "compact"
|
||||
| "workspace"
|
||||
| "integrations"
|
||||
| "experimental"
|
||||
// GSD subcommand surfaces (S02)
|
||||
| "gsd-status"
|
||||
| "gsd-visualize"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ export interface SettingsPreferencesData {
|
|||
timeoutMinutes?: number
|
||||
pollIntervalSeconds?: number
|
||||
}
|
||||
experimental?: {
|
||||
rtk?: boolean
|
||||
}
|
||||
scope: "global" | "project"
|
||||
path: string
|
||||
warnings?: string[]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue