From b21c8ac6c718880fb5c75250c8163d28e2655bce Mon Sep 17 00:00:00 2001 From: uniplanck <198168437+uniplanck@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:44:15 +0900 Subject: [PATCH 1/4] fix: add current ChatGPT model compatibility update --- agents/design.md | 15 + agents/explore.md | 22 + agents/implement.md | 22 + agents/review.md | 15 + .../CHANGELOG.md | 18 + .../README.md | 91 + .../apply.mjs | 90 + .../docs/ARCHITECTURE.md | 98 + .../docs/SECURITY_AND_PRIVACY.md | 67 + .../docs/TEST_MATRIX.md | 57 + .../manifest.json | 50 + .../0001-compact-workspace-and-usage.patch | 1197 +++++++++++++ ...2-safe-tools-and-compound-inspection.patch | 1434 +++++++++++++++ .../0003-agents-skills-app-integration.patch | 1574 +++++++++++++++++ .../verify.mjs | 72 + docs/configuration.md | 61 +- package.json | 3 +- skills/design-system-audit/SKILL.md | 44 + skills/design-system-audit/agents/openai.yaml | 4 + skills/product-ui-review/SKILL.md | 43 + skills/product-ui-review/agents/openai.yaml | 4 + .../responsive-accessibility-audit/SKILL.md | 43 + .../agents/openai.yaml | 4 + src/cli.ts | 5 +- src/compound-tools.test.ts | 99 ++ src/compound-tools.ts | 592 +++++++ src/config.test.ts | 70 +- src/config.ts | 69 +- src/design-audit.test.ts | 120 ++ src/design-audit.ts | 338 ++++ src/local-agent-profiles.test.ts | 69 +- src/local-agent-profiles.ts | 36 +- src/local-agent-runtime.test.ts | 6 + src/local-agent-runtime.ts | 3 + src/local-agent-targets.test.ts | 2 + src/pi-tools.test.ts | 51 + src/pi-tools.ts | 99 +- src/register-v11-tools.test.ts | 26 + src/register-v11-tools.ts | 336 ++++ src/safe-inspection.ts | 66 + src/server.ts | 292 ++- src/skill-matcher.test.ts | 118 ++ src/skill-matcher.ts | 276 +++ src/skills.test.ts | 16 + src/skills.ts | 2 +- src/tool-metrics.ts | 92 + src/usage-meter.test.ts | 46 + src/usage-meter.ts | 176 ++ src/workspaces.test.ts | 15 +- src/workspaces.ts | 18 +- 50 files changed, 7994 insertions(+), 72 deletions(-) create mode 100644 agents/design.md create mode 100644 agents/explore.md create mode 100644 agents/implement.md create mode 100644 agents/review.md create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/CHANGELOG.md create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/README.md create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/apply.mjs create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/docs/ARCHITECTURE.md create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/docs/SECURITY_AND_PRIVACY.md create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/docs/TEST_MATRIX.md create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/manifest.json create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/patches/0001-compact-workspace-and-usage.patch create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/patches/0002-safe-tools-and-compound-inspection.patch create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/patches/0003-agents-skills-app-integration.patch create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/verify.mjs create mode 100644 skills/design-system-audit/SKILL.md create mode 100644 skills/design-system-audit/agents/openai.yaml create mode 100644 skills/product-ui-review/SKILL.md create mode 100644 skills/product-ui-review/agents/openai.yaml create mode 100644 skills/responsive-accessibility-audit/SKILL.md create mode 100644 skills/responsive-accessibility-audit/agents/openai.yaml create mode 100644 src/compound-tools.test.ts create mode 100644 src/compound-tools.ts create mode 100644 src/design-audit.test.ts create mode 100644 src/design-audit.ts create mode 100644 src/pi-tools.test.ts create mode 100644 src/register-v11-tools.test.ts create mode 100644 src/register-v11-tools.ts create mode 100644 src/safe-inspection.ts create mode 100644 src/skill-matcher.test.ts create mode 100644 src/skill-matcher.ts create mode 100644 src/tool-metrics.ts create mode 100644 src/usage-meter.test.ts create mode 100644 src/usage-meter.ts diff --git a/agents/design.md b/agents/design.md new file mode 100644 index 00000000..a3a09e9c --- /dev/null +++ b/agents/design.md @@ -0,0 +1,15 @@ +--- +schema: devspace-agent/v1 +name: design +description: Read-only UI and UX audit specialist requiring real rendered evidence. +provider: codex +write-mode: read_only +thinking: high +--- + +Audit visual hierarchy, spacing, typography, responsiveness, mobile usability, accessibility, +contrast, focus/hover/selected states, overflow, and design-system consistency. Use design_audit +artifacts when available. If real screenshots or rendered evidence are unavailable, report the +audit as unverified and do not issue a passing judgment. + +Return evidence, findings, affected routes/components, and the next validation action. diff --git a/agents/explore.md b/agents/explore.md new file mode 100644 index 00000000..70abf441 --- /dev/null +++ b/agents/explore.md @@ -0,0 +1,22 @@ +--- +schema: devspace-agent/v1 +name: explore +description: Read-only specialist for structure, root-cause, and impact investigation. +provider: codex +write-mode: read_only +thinking: high +--- + +Investigate without editing, staging, committing, pushing, deploying, or changing external state. +Prefer CodeGraph when available, then focused search and only necessary reads. Do not perform broad scans. + +Return: + +```text +rootCause: +relevantFiles: +relevantSymbols: +impactRadius: +evidence: +nextAction: +``` diff --git a/agents/implement.md b/agents/implement.md new file mode 100644 index 00000000..638397ed --- /dev/null +++ b/agents/implement.md @@ -0,0 +1,22 @@ +--- +schema: devspace-agent/v1 +name: implement +description: Focused implementation specialist for verified, minimal code changes. +provider: codex +write-mode: allowed +thinking: high +--- + +Confirm the root cause, then implement the smallest correct change. Preserve unrelated and +uncommitted work. Do not add dependencies without proof, and do not deploy, push, commit, or +repeat builds/tests without a specific reason. + +Return: + +```text +rootCause: +changes: +tests: +risks: +remaining: +``` diff --git a/agents/review.md b/agents/review.md new file mode 100644 index 00000000..9739d9d6 --- /dev/null +++ b/agents/review.md @@ -0,0 +1,15 @@ +--- +schema: devspace-agent/v1 +name: review +description: Read-only diff reviewer for correctness, security, regression, and scope risk. +provider: codex +write-mode: read_only +thinking: high +--- + +Review without editing or changing Git state. Inspect correctness, security, type safety, +performance, regressions, scope creep, dependencies, permission boundaries, secret exposure, +payload growth, and compact-mode regressions. + +Return findings in severity order with evidence, affected files, and a recommended fix. +Explicitly state when no actionable issue is found. diff --git a/compatibility-kit/openai-model-compatibility-2026-07/CHANGELOG.md b/compatibility-kit/openai-model-compatibility-2026-07/CHANGELOG.md new file mode 100644 index 00000000..1c706499 --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## 2026.07.1 + +- Added compact `open_workspace` payload behavior with bounded instruction + excerpts and explicit full reads. +- Added exact-path access for advertised instruction files outside the project + root without widening directory access. +- Added optional text-volume diagnostics. +- Added optional compound inspection tools and safe inspection helpers. +- Added explicitly configured approved-shell aliases with workspace validation. +- Added optional built-in agent profiles, skill matching, design-audit tooling, + and generic skill templates. +- Added tests, feature flags, configuration documentation, and updater scripts. +- Removed private branding, personal paths, private endpoints, configuration + values, and local diagnostic artifacts from the public proposal. +- Documented that the compatibility workflow was prepared through ChatGPT + instructions using DevSpace itself. diff --git a/compatibility-kit/openai-model-compatibility-2026-07/README.md b/compatibility-kit/openai-model-compatibility-2026-07/README.md new file mode 100644 index 00000000..3a9cd85c --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/README.md @@ -0,0 +1,91 @@ +# DevSpace OpenAI Model Compatibility Kit + +This directory packages a reviewable compatibility update for DevSpace v1.0.4. +It was prepared after a recent ChatGPT model rollout changed the practical +behavior of multi-step MCP tool execution. The observed regression affected both +GPT-5.5 and GPT-5.6: operations that had previously completed normally became +slow, stalled, or unreliable. + +This is intentionally described as a compatibility update rather than a +GPT-5.6-only patch. No assumption is made about undocumented OpenAI internals. + +## What this kit changes + +- Reduces the initial `open_workspace` payload through bounded instruction + excerpts and lazy full-file reads. +- Keeps advertised instruction files readable without widening the workspace + filesystem allowlist. +- Adds optional text-volume metrics so maintainers can measure response size and + identify avoidable context expansion. +- Adds opt-in compound inspection tools for common, bounded read-only workflows. +- Adds safer support for explicitly pre-approved shell command aliases. +- Improves built-in agent profile, skill matching, design-audit, and MCP App + integration paths while keeping the features opt-in. +- Includes tests for the compatibility behavior and feature flags. + +## Scope and privacy + +The bundle contains only generic DevSpace changes. It does not contain: + +- personal filesystem paths or usernames; +- private repositories or private integration endpoints; +- credentials, access tokens, cookies, owner passwords, or private keys; +- machine-specific allowlists or approved command configurations; +- custom product names or private branding; +- personal logs, usage history, or screenshots. + +See `docs/SECURITY_AND_PRIVACY.md` for the sanitization rules. + +## Requirements + +- A clean checkout of `Waishnav/devspace` at release `v1.0.4` or commit + `d03187460cebdc2820e797cb59740537100a0f99`. +- Node.js compatible with the repository's `engines` field. +- Git available on `PATH`. + +## Apply + +From the root of a clean DevSpace v1.0.4 checkout: + + node compatibility-kit/openai-model-compatibility-2026-07/apply.mjs + +The script performs all `git apply --check` operations before changing files. It +refuses to run against a dirty working tree or an unsupported package/version. + +Then validate: + + node compatibility-kit/openai-model-compatibility-2026-07/verify.mjs + +The verification script runs dependency installation, type checking, tests, and +the production build. Review the diff before committing. + +## Roll back + +Before applying, create a branch or commit in the target checkout. If the patch +has not been committed, restore the checkout with Git after reviewing the +changes. The apply script does not commit, push, publish, deploy, or modify user +configuration. + +## Maintainer review + +The patch set is split by responsibility: + +1. `0001-compact-workspace-and-usage.patch` +2. `0002-safe-tools-and-compound-inspection.patch` +3. `0003-agents-skills-app-integration.patch` + +The same changes are also present directly in the pull-request branch, so the +maintainer can review normal source diffs without running the updater. + +## Dogfooding note + +The investigation, sanitization, compatibility-kit preparation, validation, and +GitHub delivery workflow were executed from ChatGPT instructions using DevSpace +itself. This demonstrates the exact MCP workflow being improved; it is not a +security guarantee or a substitute for maintainer review. + +## Status + +This is a community compatibility proposal, not an official DevSpace release. +Feature flags remain opt-in where practical, and upstream maintainers retain +full control over naming, versioning, scope, and release decisions. diff --git a/compatibility-kit/openai-model-compatibility-2026-07/apply.mjs b/compatibility-kit/openai-model-compatibility-2026-07/apply.mjs new file mode 100644 index 00000000..c1612d8e --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/apply.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const kitDir = dirname(fileURLToPath(import.meta.url)); +const manifest = JSON.parse(await readFile(resolve(kitDir, "manifest.json"), "utf8")); +const args = process.argv.slice(2); +const repoArgIndex = args.indexOf("--repo"); +const repoRoot = resolve(repoArgIndex >= 0 ? args[repoArgIndex + 1] ?? "" : process.cwd()); +const kitRelativePrefix = "compatibility-kit/openai-model-compatibility-2026-07/"; + +function run(command, commandArgs, options = {}) { + const result = spawnSync(command, commandArgs, { + cwd: repoRoot, + encoding: "utf8", + stdio: options.capture ? "pipe" : "inherit", + }); + if (result.error) throw result.error; + return result; +} + +function requireSuccess(result, message) { + if (result.status !== 0) { + const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + throw new Error(detail ? `${message}\n${detail}` : message); + } +} + +const packagePath = resolve(repoRoot, "package.json"); +let packageJson; +try { + packageJson = JSON.parse(await readFile(packagePath, "utf8")); +} catch { + throw new Error(`Could not read a package.json at ${repoRoot}`); +} + +if (packageJson.name !== manifest.target.package) { + throw new Error(`Expected ${manifest.target.package}, found ${packageJson.name ?? "unknown"}.`); +} +if (packageJson.version !== manifest.target.version) { + throw new Error( + `This bundle targets DevSpace ${manifest.target.version}; found ${packageJson.version ?? "unknown"}.`, + ); +} + +const inside = run("git", ["rev-parse", "--is-inside-work-tree"], { capture: true }); +requireSuccess(inside, "The target directory is not a Git working tree."); + +const status = run("git", ["status", "--porcelain=v1"], { capture: true }); +requireSuccess(status, "Could not inspect the target working tree."); +const blockingChanges = status.stdout + .split("\n") + .map((line) => line.trimEnd()) + .filter(Boolean) + .filter((line) => !line.slice(3).startsWith(kitRelativePrefix)); +if (blockingChanges.length > 0) { + throw new Error( + "Refusing to patch a dirty working tree. Commit, stash, or remove existing changes first.", + ); +} + +for (const patch of manifest.patches) { + const patchPath = resolve(kitDir, patch.path); + const check = run("git", ["apply", "--check", "--whitespace=error-all", patchPath], { + capture: true, + }); + requireSuccess(check, `Patch preflight failed: ${patch.path}`); +} + +const applied = []; +try { + for (const patch of manifest.patches) { + const patchPath = resolve(kitDir, patch.path); + const apply = run("git", ["apply", "--whitespace=fix", patchPath], { capture: true }); + requireSuccess(apply, `Could not apply ${patch.path}`); + applied.push(patchPath); + console.log(`Applied ${patch.path}`); + } +} catch (error) { + for (const patchPath of applied.reverse()) { + run("git", ["apply", "--reverse", patchPath], { capture: true }); + } + throw error; +} + +console.log("Compatibility patches applied. No commit, push, publish, or deploy was performed."); +console.log("Run verify.mjs, inspect git diff, and commit only after review."); diff --git a/compatibility-kit/openai-model-compatibility-2026-07/docs/ARCHITECTURE.md b/compatibility-kit/openai-model-compatibility-2026-07/docs/ARCHITECTURE.md new file mode 100644 index 00000000..5fef6fde --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/docs/ARCHITECTURE.md @@ -0,0 +1,98 @@ +# Architecture and Integration Notes + +## Problem statement + +After a recent ChatGPT model rollout, multi-step DevSpace sessions became +materially slower or less reliable with both GPT-5.5 and GPT-5.6. The practical +failure mode was not limited to one model name: large initial tool payloads and +verbose repeated tool results increased the amount of context exchanged before +useful work could begin. + +This proposal does not depend on undocumented model internals. It reduces the +amount of information sent eagerly, makes follow-up reads explicit, and adds +bounded higher-level operations for common workflows. + +## Workspace lifecycle + +The MCP client calls `open_workspace` once for a checkout or managed worktree. +The returned `workspaceId` is then reused for file reads, searches, edits, +writes, and shell execution. + +The compatibility behavior changes the initial response as follows: + +- Instruction files are represented by bounded excerpts in compact mode. +- Full instruction content remains available through the existing `read` tool. +- Nested instruction paths are advertised without eagerly sending every file. +- Skill descriptions and diagnostics are omitted from the initial compact + payload when they are not needed. +- Payload and instruction-character metrics are returned for diagnosis. + +This preserves the instruction-following contract while reducing initial MCP +response size. + +## Safe instruction reads + +An instruction file may live outside the selected project root, for example in +a user-level agent configuration directory. The workspace registry records only +instruction paths that were explicitly discovered and advertised during +workspace opening. A later `read` can access those exact files, but this does not +create a general read permission for their parent directories. + +## Tool result metrics + +The usage meter estimates text volume from characters handled by DevSpace. It is +not OpenAI usage, token billing, or a model-provider measurement. Its purpose is +to compare payload sizes and identify unnecessary context expansion. + +The reporting mode is configurable: + +- `off`: do not append estimates to tool results; +- `compact`: append a one-line estimate; +- `full`: append per-tool and estimated-savings details. + +History writes are diagnostic-only and cannot fail the underlying tool call. + +## Compound tools + +Optional compound tools combine bounded, commonly repeated read-only steps. +They reduce round trips without introducing an autonomous execution loop. Each +compound tool retains explicit input schemas, workspace scoping, output limits, +and the same path-resolution rules as the primitive tools. + +The primitive file and shell tools remain available. Compound tools are disabled +unless explicitly enabled. + +## Approved shell aliases + +The approved-command mechanism does not accept an arbitrary command definition +from the MCP client. The client supplies only an alias. DevSpace resolves that +alias from a local configuration file and verifies: + +- the alias syntax; +- that the entry is enabled; +- that its configured workspace root exactly matches the active workspace; +- that the configured working directory remains within the allowed root; +- that the locally configured command is non-empty. + +The local approved-command file is not included in this compatibility bundle. + +## Agents, skills, and Apps + +The proposal adds generic built-in agent profiles and skills as optional +capabilities. It also keeps MCP App metadata and structured tool content aligned +with compact-mode results. These components are generic templates and contain +no private integration destinations or user-specific configuration. + +Feature flags allow maintainers and users to adopt the capabilities separately: + +- `DEVSPACE_SKILL_MATCHER` +- `DEVSPACE_COMPOUND_TOOLS` +- `DEVSPACE_BUILTIN_PROFILES` +- `DEVSPACE_DESIGN_AUDIT` + +## Compatibility posture + +The branch keeps the upstream package version unchanged. Version selection and +release publication belong to the upstream maintainer. The compatibility kit +applies source changes only and never commits, pushes, publishes, deploys, or +changes user configuration. diff --git a/compatibility-kit/openai-model-compatibility-2026-07/docs/SECURITY_AND_PRIVACY.md b/compatibility-kit/openai-model-compatibility-2026-07/docs/SECURITY_AND_PRIVACY.md new file mode 100644 index 00000000..ca546dd1 --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/docs/SECURITY_AND_PRIVACY.md @@ -0,0 +1,67 @@ +# Security and Privacy Review + +## Public-release boundary + +This bundle was produced from a customized DevSpace installation, but only +generic source changes were transferred into the public worktree. The public +branch starts from the upstream v1.0.4 commit and does not include private commit +history from the customized installation. + +## Excluded data + +The following categories are intentionally excluded: + +- personal names, account handles, and local usernames; +- absolute personal project paths; +- private repository names and internal project identifiers; +- Tailscale addresses, tunnel URLs, private hostnames, and callback URLs; +- OAuth owner passwords, access tokens, refresh tokens, cookies, and API keys; +- SSH keys, signing keys, certificates, and cloud credentials; +- local allowed-root lists and approved-command configuration values; +- personal usage logs, shell history, diagnostic history, and screenshots; +- custom product branding that is not part of upstream DevSpace. + +Generic references to supported technologies such as Cloudflare Tunnel, +Tailscale, OAuth, GitHub, ChatGPT, or MCP may remain when they are part of normal +product documentation rather than a private endpoint or credential. + +## Sanitization checks + +The verification script scans changed and untracked text files for known private +markers before running the build. This is a defense-in-depth check, not a proof +that arbitrary unknown secrets cannot exist. + +Reviewers should still inspect: + +- the complete Git diff; +- newly added JSON and YAML files; +- examples involving paths, commands, hosts, or environment variables; +- generated patch files before publication. + +## Local configuration remains local + +The approved-shell feature reads a local configuration file at runtime. This +bundle includes the loader and validation logic but does not include any real +approved command entry. Users must create their own configuration outside the +repository. + +Usage history is also stored outside the repository by default. No history file +is included in the patch set. + +## Safety properties + +- Existing workspace root checks remain in force. +- Advertised external instruction reads are restricted to exact discovered + paths rather than entire directories. +- Optional compound tools remain workspace-scoped and bounded. +- Optional design inspection restricts target hosts through an allowlist. +- The updater checks every patch before applying any patch. +- The updater refuses dirty target trees. +- The updater performs no Git commit, push, publish, deployment, or user-config + mutation. + +## Provenance note + +The public preparation workflow was directed through ChatGPT and executed using +DevSpace's own MCP tools. This dogfooding statement describes the workflow only; +it does not imply endorsement by OpenAI or the upstream DevSpace maintainer. diff --git a/compatibility-kit/openai-model-compatibility-2026-07/docs/TEST_MATRIX.md b/compatibility-kit/openai-model-compatibility-2026-07/docs/TEST_MATRIX.md new file mode 100644 index 00000000..f4439f6d --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/docs/TEST_MATRIX.md @@ -0,0 +1,57 @@ +# Validation Matrix + +## Automated checks + +The public branch must pass: + +- `npm ci` +- `npm run typecheck` +- `npm test` +- `npm run build` +- privacy-marker scan from `verify.mjs` +- `git apply --check` for every compatibility patch against upstream v1.0.4 + +## Behavioral checks + +### Workspace opening + +- Compact mode returns a valid `workspaceId`. +- Loaded instruction files include bounded content and truncation metadata. +- Full mode preserves the prior full-payload behavior. +- A full read of an advertised instruction path succeeds. +- A non-advertised neighboring path remains blocked. + +### Primitive tools + +- `read`, `write`, `edit`, `grep`, `glob`, `ls`, and `bash` preserve their + existing structured results. +- Usage reporting can be disabled. +- Usage reporting cannot break a successful underlying tool call. + +### Approved commands + +- Unknown aliases fail. +- Invalid alias syntax fails. +- A command configured for another workspace fails. +- A configured working directory cannot escape the active workspace. +- Arbitrary normal shell commands continue through the existing shell path and + are not rewritten as approved aliases. + +### Optional capabilities + +- Skill matcher disabled by default. +- Compound tools disabled by default. +- Built-in profiles disabled by default. +- Design audit disabled by default. +- Invalid feature-flag values fail configuration loading. + +### MCP client smoke test + +The original regression was observed in ChatGPT sessions using GPT-5.5 and +GPT-5.6. A manual smoke test should open one repository, read one instruction +file, list a directory, run a harmless Git inspection command, and complete a +small multi-step task with both available model families. + +Model-provider behavior can change independently of this repository, so manual +results should include the test date and client environment rather than being +treated as permanent guarantees. diff --git a/compatibility-kit/openai-model-compatibility-2026-07/manifest.json b/compatibility-kit/openai-model-compatibility-2026-07/manifest.json new file mode 100644 index 00000000..731e87a5 --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/manifest.json @@ -0,0 +1,50 @@ +{ + "name": "devspace-openai-model-compatibility", + "bundleVersion": "2026.07.1", + "status": "community-proposal", + "target": { + "package": "@waishnav/devspace", + "version": "1.0.4", + "commit": "d03187460cebdc2820e797cb59740537100a0f99" + }, + "patches": [ + { + "order": 1, + "path": "patches/0001-compact-workspace-and-usage.patch", + "scope": "Compact workspace payloads, instruction reads, feature flags, and text-volume metrics" + }, + { + "order": 2, + "path": "patches/0002-safe-tools-and-compound-inspection.patch", + "scope": "Approved shell aliases, bounded inspection helpers, compound tools, and registration" + }, + { + "order": 3, + "path": "patches/0003-agents-skills-app-integration.patch", + "scope": "Agent profiles, skills, design audit, configuration docs, and MCP App integration" + } + ], + "defaults": { + "openWorkspacePayload": "compact", + "usageContent": "compact", + "skillMatcher": false, + "compoundTools": false, + "builtinProfiles": false, + "designAudit": false + }, + "privacy": { + "personalPathsIncluded": false, + "credentialsIncluded": false, + "privateEndpointsIncluded": false, + "privateBrandingIncluded": false, + "personalLogsIncluded": false + }, + "automation": { + "commits": false, + "pushes": false, + "publishes": false, + "deploys": false, + "userConfigMutations": false + }, + "provenance": "Prepared and validated through ChatGPT instructions using DevSpace MCP tools." +} diff --git a/compatibility-kit/openai-model-compatibility-2026-07/patches/0001-compact-workspace-and-usage.patch b/compatibility-kit/openai-model-compatibility-2026-07/patches/0001-compact-workspace-and-usage.patch new file mode 100644 index 00000000..fcd2b914 --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/patches/0001-compact-workspace-and-usage.patch @@ -0,0 +1,1197 @@ +diff --git a/package.json b/package.json +index af895b3..b6ad4b4 100644 +--- a/package.json ++++ b/package.json +@@ -14,6 +14,7 @@ + "dist", + "docs", + "examples", ++ "agents", + "scripts", + "skills", + "README.md" +@@ -28,7 +29,7 @@ + "dev": "node scripts/dev-server.mjs", + "postinstall": "node scripts/fix-node-pty-permissions.mjs", + "start": "node dist/cli.js serve", +- "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", ++ "test": "tsx src/config.test.ts && tsx src/usage-meter.test.ts && tsx src/pi-tools.test.ts && tsx src/skill-matcher.test.ts && tsx src/compound-tools.test.ts && tsx src/design-audit.test.ts && tsx src/register-v11-tools.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "keywords": [], +diff --git a/src/config.test.ts b/src/config.test.ts +index 0b4f99a..c0ad2c4 100644 +--- a/src/config.test.ts ++++ b/src/config.test.ts +@@ -12,7 +12,36 @@ const baseEnv = { + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }; + +-assert.equal(loadConfig(baseEnv).widgets, "full"); ++assert.equal(loadConfig(baseEnv).openWorkspacePayload, "compact"); ++assert.equal(loadConfig(baseEnv).openWorkspaceInstructionChars, 6_000); ++assert.equal(loadConfig(baseEnv).usageContent, "compact"); ++assert.equal(loadConfig(baseEnv).skillMatcher, false); ++assert.equal(loadConfig(baseEnv).compoundTools, false); ++assert.equal(loadConfig(baseEnv).builtinProfiles, false); ++assert.equal(loadConfig(baseEnv).designAudit, false); ++assert.deepEqual(loadConfig(baseEnv).designAuditAllowedHosts, ["localhost", "127.0.0.1", "::1"]); ++assert.equal(loadConfig(baseEnv).widgets, "off"); ++assert.equal( ++ loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_PAYLOAD: "full" }).widgets, ++ "full", ++); ++assert.equal( ++ loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_PAYLOAD: "full" }).openWorkspacePayload, ++ "full", ++); ++assert.equal( ++ loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS: "8000" }) ++ .openWorkspaceInstructionChars, ++ 8_000, ++); ++assert.equal( ++ loadConfig({ ...baseEnv, DEVSPACE_USAGE_CONTENT: "off" }).usageContent, ++ "off", ++); ++assert.equal( ++ loadConfig({ ...baseEnv, DEVSPACE_USAGE_CONTENT: "full" }).usageContent, ++ "full", ++); + assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); + assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); + assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); +@@ -60,6 +89,35 @@ assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), + /Invalid DEVSPACE_TOOL_MODE: invalid/, + ); ++assert.throws( ++ () => loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_PAYLOAD: "invalid" }), ++ /Invalid DEVSPACE_OPEN_WORKSPACE_PAYLOAD: invalid/, ++); ++assert.throws( ++ () => loadConfig({ ...baseEnv, DEVSPACE_USAGE_CONTENT: "invalid" }), ++ /Invalid DEVSPACE_USAGE_CONTENT: invalid/, ++); ++assert.throws( ++ () => loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS: "0" }), ++ /Invalid DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS: 0/, ++); ++assert.throws( ++ () => loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS: "255" }), ++ /Invalid DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS: 255/, ++); ++for (const name of [ ++ "DEVSPACE_SKILL_MATCHER", ++ "DEVSPACE_COMPOUND_TOOLS", ++ "DEVSPACE_BUILTIN_PROFILES", ++ "DEVSPACE_DESIGN_AUDIT", ++]) { ++ assert.equal(loadConfig({ ...baseEnv, [name]: "true" })[featureKey(name)], true); ++ assert.equal(loadConfig({ ...baseEnv, [name]: "off" })[featureKey(name)], false); ++ assert.throws( ++ () => loadConfig({ ...baseEnv, [name]: "invalid" }), ++ new RegExp(`Invalid ${name}: invalid`), ++ ); ++} + + assert.deepEqual(loadConfig(baseEnv).logging, { + level: "info", +@@ -71,6 +129,16 @@ assert.deepEqual(loadConfig(baseEnv).logging, { + trustProxy: false, + }); + ++function featureKey(name: string): "skillMatcher" | "compoundTools" | "builtinProfiles" | "designAudit" { ++ switch (name) { ++ case "DEVSPACE_SKILL_MATCHER": return "skillMatcher"; ++ case "DEVSPACE_COMPOUND_TOOLS": return "compoundTools"; ++ case "DEVSPACE_BUILTIN_PROFILES": return "builtinProfiles"; ++ case "DEVSPACE_DESIGN_AUDIT": return "designAudit"; ++ default: throw new Error(`Unknown feature flag: ${name}`); ++ } ++} ++ + assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "silent" }).logging.level, "silent"); + assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "error" }).logging.level, "error"); + assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "warn" }).logging.level, "warn"); +diff --git a/src/config.ts b/src/config.ts +index 4fc1bcb..019637c 100644 +--- a/src/config.ts ++++ b/src/config.ts +@@ -7,6 +7,8 @@ import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user- + + export type ToolMode = "minimal" | "full" | "codex"; + export type WidgetMode = "off" | "changes" | "full"; ++export type OpenWorkspacePayloadMode = "compact" | "full"; ++export type UsageContentMode = "off" | "compact" | "full"; + const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; + const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; + +@@ -19,6 +21,14 @@ export interface ServerConfig { + publicBaseUrl: string; + toolMode: ToolMode; + widgets: WidgetMode; ++ openWorkspacePayload: OpenWorkspacePayloadMode; ++ openWorkspaceInstructionChars: number; ++ usageContent: UsageContentMode; ++ skillMatcher: boolean; ++ compoundTools: boolean; ++ builtinProfiles: boolean; ++ designAudit: boolean; ++ designAuditAllowedHosts: string[]; + stateDir: string; + worktreeRoot: string; + skillsEnabled: boolean; +@@ -81,6 +91,14 @@ function parseBoolean(value: string | undefined): boolean { + return ["1", "true", "yes", "on"].includes(value?.toLowerCase() ?? ""); + } + ++function parseFeatureFlag(value: string | undefined, name: string): boolean { ++ if (value === undefined) return false; ++ const normalized = value.trim().toLowerCase(); ++ if (["1", "true", "yes", "on"].includes(normalized)) return true; ++ if (["0", "false", "no", "off"].includes(normalized)) return false; ++ throw new Error(`Invalid ${name}: ${value}`); ++} ++ + function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { + const mode = env.DEVSPACE_TOOL_MODE; + if (mode === "minimal" || mode === "full" || mode === "codex") return mode; +@@ -135,6 +153,19 @@ function parsePositiveInteger(value: string | undefined, fallback: number, name: + return parsed; + } + ++function parseIntegerAtLeast( ++ value: string | undefined, ++ fallback: number, ++ name: string, ++ minimum: number, ++): number { ++ const parsed = parsePositiveInteger(value, fallback, name); ++ if (parsed < minimum) { ++ throw new Error(`Invalid ${name}: ${value}`); ++ } ++ return parsed; ++} ++ + function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { + return { + level: parseLogLevel(env.DEVSPACE_LOG_LEVEL), +@@ -147,8 +178,23 @@ function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { + }; + } + +-function parseWidgetMode(value: string | undefined): WidgetMode { +- if (!value || value === "full") return "full"; ++function parseOpenWorkspacePayloadMode(value: string | undefined): OpenWorkspacePayloadMode { ++ if (!value || value === "compact") return "compact"; ++ if (value === "full") return value; ++ ++ throw new Error(`Invalid DEVSPACE_OPEN_WORKSPACE_PAYLOAD: ${value}`); ++} ++ ++function parseUsageContentMode(value: string | undefined): UsageContentMode { ++ if (!value || value === "compact") return "compact"; ++ if (value === "off" || value === "full") return value; ++ ++ throw new Error(`Invalid DEVSPACE_USAGE_CONTENT: ${value}`); ++} ++ ++function parseWidgetMode(value: string | undefined, fallback: WidgetMode): WidgetMode { ++ if (!value) return fallback; ++ if (value === "full") return "full"; + if (value === "off" || value === "changes") return value; + + throw new Error(`Invalid DEVSPACE_WIDGETS: ${value}`); +@@ -206,6 +252,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { + const publicBaseUrl = parsePublicBaseUrl( + env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), + ); ++ const openWorkspacePayload = parseOpenWorkspacePayloadMode(env.DEVSPACE_OPEN_WORKSPACE_PAYLOAD); + const derivedAllowedHosts = [ + "localhost", + "127.0.0.1", +@@ -223,7 +270,23 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { + allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), + publicBaseUrl, + toolMode: parseToolMode(env), +- widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), ++ widgets: parseWidgetMode(env.DEVSPACE_WIDGETS, openWorkspacePayload === "compact" ? "off" : "full"), ++ openWorkspacePayload, ++ openWorkspaceInstructionChars: parseIntegerAtLeast( ++ env.DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS, ++ 6_000, ++ "DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS", ++ 256, ++ ), ++ usageContent: parseUsageContentMode(env.DEVSPACE_USAGE_CONTENT), ++ skillMatcher: parseFeatureFlag(env.DEVSPACE_SKILL_MATCHER, "DEVSPACE_SKILL_MATCHER"), ++ compoundTools: parseFeatureFlag(env.DEVSPACE_COMPOUND_TOOLS, "DEVSPACE_COMPOUND_TOOLS"), ++ builtinProfiles: parseFeatureFlag(env.DEVSPACE_BUILTIN_PROFILES, "DEVSPACE_BUILTIN_PROFILES"), ++ designAudit: parseFeatureFlag(env.DEVSPACE_DESIGN_AUDIT, "DEVSPACE_DESIGN_AUDIT"), ++ designAuditAllowedHosts: parseStringList( ++ env.DEVSPACE_DESIGN_AUDIT_ALLOWED_HOSTS, ++ ["localhost", "127.0.0.1", "::1"], ++ ), + stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), + worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), + skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), +diff --git a/src/server.ts b/src/server.ts +index c72e143..93edc39 100644 +--- a/src/server.ts ++++ b/src/server.ts +@@ -35,6 +35,13 @@ import { + runShellTool, + writeFileTool, + } from "./pi-tools.js"; ++import { ++ appendUsageToContent, ++ editInputChars, ++ estimateFileChars, ++ recordObservedToolUsage, ++ textContentChars, ++} from "./usage-meter.js"; + import { SingleUserOAuthProvider } from "./oauth-provider.js"; + import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; + import { createReviewCheckpointManager } from "./review-checkpoints.js"; +@@ -47,6 +54,7 @@ import { + getLocalAgentProviderAvailabilitySnapshot, + type LocalAgentProviderAvailability, + } from "./local-agent-availability.js"; ++import { registerV11Tools } from "./register-v11-tools.js"; + + type Transport = StreamableHTTPServerTransport; + const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; +@@ -187,7 +195,9 @@ function serverInstructions(config: ServerConfig): string { + ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` + : ""; + +- const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; ++ const agentsMd = config.openWorkspacePayload === "compact" ++ ? `Follow instructions returned by ${toolNames.openWorkspace}. It returns bounded instruction excerpts to keep the initial result small; use ${toolNames.read} to read every path listed in agentsFiles before other project work, and read applicable paths in availableAgentsFiles before working in their scope. ` ++ : `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; + + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChangesInstruction}`; + } +@@ -225,13 +235,15 @@ function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { + + const workspaceSkillOutputSchema = z.object({ + name: z.string(), +- description: z.string(), ++ description: z.string().optional(), + path: z.string(), + }); + + const workspaceAgentsFileOutputSchema = z.object({ + path: z.string(), +- content: z.string(), ++ content: z.string().optional(), ++ characters: z.number().int().nonnegative().optional(), ++ truncated: z.boolean().optional(), + }); + + const workspaceLocalAgentOutputSchema = z.object({ +@@ -240,6 +252,7 @@ const workspaceLocalAgentOutputSchema = z.object({ + provider: z.string(), + model: z.string().optional(), + thinking: z.string().optional(), ++ writeMode: z.enum(["read_only", "allowed"]).optional(), + providerAvailable: z.boolean().optional(), + providerUnavailableReason: z.string().optional(), + }); +@@ -335,6 +348,23 @@ function textBlock(text: string): ToolContent { + return { type: "text", text }; + } + ++function compactInstructionContent(content: string, limit: number): { ++ content: string; ++ truncated: boolean; ++} { ++ if (content.length <= limit) return { content, truncated: false }; ++ ++ const marker = "\n\n[... instruction file truncated by GPT-5.6 compact mode; use read with this path for the full file ...]\n\n"; ++ const available = Math.max(0, limit - marker.length); ++ const headLength = Math.ceil(available * 0.7); ++ const tailLength = available - headLength; ++ ++ return { ++ content: `${content.slice(0, headLength)}${marker}${content.slice(content.length - tailLength)}`, ++ truncated: true, ++ }; ++} ++ + function textSummary(content: ToolContent[]): { + lines: number; + characters: number; +@@ -730,7 +760,7 @@ function createMcpServer( + { + title: "Open workspace", + description: +- "Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder; do not call open_workspace again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for an isolated or parallel coding session. Returns a workspaceId, loaded root project instructions, and nested instruction file paths the model should read before working in those directories.", ++ "Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder. In compact mode, instruction files are returned as bounded excerpts and can be read in full through the read tool.", + inputSchema: { + path: z + .string() +@@ -765,31 +795,49 @@ function createMcpServer( + .optional(), + agentsFiles: z.array(workspaceAgentsFileOutputSchema), + availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), ++ availableAgentsFilesTotal: z.number().int().nonnegative(), ++ availableAgentsFilesTruncated: z.boolean(), + skills: z.array(workspaceSkillOutputSchema), + agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), + agents: z.array(workspaceLocalAgentOutputSchema), + skillDiagnostics: z.array(z.unknown()), + instruction: z.string(), ++ metrics: z.object({ ++ compact: z.boolean(), ++ serverDurationMs: z.number().int().nonnegative(), ++ payloadCharacters: z.number().int().nonnegative(), ++ fullInstructionCharacters: z.number().int().nonnegative(), ++ returnedInstructionCharacters: z.number().int().nonnegative(), ++ }), + }, + ...toolWidgetDescriptorMeta(config, "workspace"), + annotations: { readOnlyHint: true }, + }, + async ({ path, mode, baseRef }) => { + const startedAt = performance.now(); ++ const compact = config.openWorkspacePayload === "compact"; + const { workspace, agentsFiles, availableAgentsFiles } = await workspaces.openWorkspace({ path, mode, baseRef }); ++ + if (config.widgets === "changes") { + void reviewCheckpoints.initializeWorkspace({ + workspaceId: workspace.id, + root: workspace.root, + }); + } ++ + const visibleSkills = workspace.skills + .filter((skill) => !skill.disableModelInvocation) +- .map((skill) => ({ +- name: skill.name, +- description: skill.description, +- path: formatPathForPrompt(skill.filePath), +- })); ++ .map((skill) => compact ++ ? { ++ name: skill.name, ++ path: formatPathForPrompt(skill.filePath), ++ } ++ : { ++ name: skill.name, ++ description: skill.description, ++ path: formatPathForPrompt(skill.filePath), ++ }); ++ + const visibleAgentProviders = config.subagents ? localAgentProviders : []; + const visibleAgents = workspace.agentProfiles.map((profile) => { + const summary = summarizeLocalAgentProfile(profile); +@@ -800,16 +848,38 @@ function createMcpServer( + providerUnavailableReason: availability?.reason, + }; + }); +- const loadedAgentsFiles = agentsFiles.map((file) => ({ +- path: formatAgentsPath(file.path, workspace.root), +- content: file.content, +- })); ++ ++ const loadedAgentsFiles = agentsFiles.map((file) => { ++ const formattedPath = formatAgentsPath(file.path, workspace.root); ++ if (!compact) { ++ return { ++ path: formattedPath, ++ content: file.content, ++ }; ++ } ++ ++ const excerpt = compactInstructionContent( ++ file.content, ++ config.openWorkspaceInstructionChars, ++ ); ++ return { ++ path: formattedPath, ++ content: excerpt.content, ++ characters: file.content.length, ++ truncated: excerpt.truncated, ++ }; ++ }); ++ + const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ + path: formatAgentsPath(file.path, workspace.root), + })); +- const instruction = config.skillsEnabled +- ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." +- : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; ++ ++ const instruction = compact ++ ? "Use this workspaceId for later calls in this project. Treat agentsFiles content as excerpts and read every listed path in full before other project work. Read applicable availableAgentsFiles before working in their nested scope. Read a skill only when the task matches it." ++ : config.skillsEnabled ++ ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." ++ : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; ++ + const resultContent: ToolContent[] = [ + { + type: "text" as const, +@@ -817,34 +887,74 @@ function createMcpServer( + `Opened workspace ${workspace.id}`, + `Root: ${workspace.root}`, + `Mode: ${workspace.mode}`, ++ compact ? "Payload: compact" : "Payload: full", + loadedAgentsFiles.length > 0 +- ? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` ++ ? compact ++ ? `Instruction files: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` ++ : `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` + : undefined, + availableAgentsFileOutputs.length > 0 +- ? `Available nested instructions: ${availableAgentsFileOutputs.map((file) => file.path).join(", ")}` ++ ? compact ++ ? `Nested instruction files: ${availableAgentsFileOutputs.length}` ++ : `Available nested instructions: ${availableAgentsFileOutputs.map((file) => file.path).join(", ")}` + : undefined, + visibleSkills.length > 0 +- ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` ++ ? `${compact ? "Skills" : "Available skills"}: ${visibleSkills.map((skill) => skill.name).join(", ")}` + : undefined, +- visibleAgentProviders.some((provider) => provider.available) ++ !compact && visibleAgentProviders.some((provider) => provider.available) + ? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map((provider) => provider.name).join(", ")}` + : undefined, +- visibleAgentProviders.some((provider) => !provider.available) ++ !compact && visibleAgentProviders.some((provider) => !provider.available) + ? `Unavailable subagent providers: ${visibleAgentProviders.filter((provider) => !provider.available).map(formatUnavailableAgentProvider).join(", ")}` + : undefined, +- visibleAgents.length > 0 ++ !compact && visibleAgents.length > 0 + ? `Available subagent profiles: ${visibleAgents.map(formatVisibleAgent).join(", ")}` + : undefined, + instruction, + ].filter(Boolean).join("\n"), + }, + ]; ++ ++ const fullInstructionCharacters = agentsFiles.reduce( ++ (total, file) => total + file.content.length, ++ 0, ++ ); ++ const returnedInstructionCharacters = loadedAgentsFiles.reduce( ++ (total, file) => total + (file.content?.length ?? 0), ++ 0, ++ ); ++ const serverDurationMs = Math.round(performance.now() - startedAt); ++ const structuredContent = { ++ workspaceId: workspace.id, ++ root: workspace.root, ++ mode: workspace.mode, ++ sourceRoot: workspace.sourceRoot, ++ worktree: workspace.worktree, ++ agentsFiles: loadedAgentsFiles, ++ availableAgentsFiles: availableAgentsFileOutputs, ++ availableAgentsFilesTotal: availableAgentsFiles.length, ++ availableAgentsFilesTruncated: availableAgentsFileOutputs.length < availableAgentsFiles.length, ++ skills: visibleSkills, ++ agentProviders: visibleAgentProviders, ++ agents: visibleAgents, ++ skillDiagnostics: compact ? [] : workspace.skillDiagnostics, ++ instruction, ++ metrics: { ++ compact, ++ serverDurationMs, ++ payloadCharacters: 0, ++ fullInstructionCharacters, ++ returnedInstructionCharacters, ++ }, ++ }; ++ structuredContent.metrics.payloadCharacters = JSON.stringify(structuredContent).length; ++ + logToolCall(config, { + tool: "open_workspace", + workspaceId: workspace.id, + path: workspace.root, + success: true, +- durationMs: Math.round(performance.now() - startedAt), ++ durationMs: serverDurationMs, + }); + + return { +@@ -856,29 +966,21 @@ function createMcpServer( + root: workspace.root, + path: workspace.root, + summary: { ++ compact, ++ payloadCharacters: structuredContent.metrics.payloadCharacters, ++ fullInstructionCharacters, ++ returnedInstructionCharacters, + agentsFiles: loadedAgentsFiles.length, + availableAgentsFiles: availableAgentsFileOutputs.length, ++ availableAgentsFilesTotal: availableAgentsFiles.length, + skills: visibleSkills.length, + agentProviders: visibleAgentProviders.length, + agents: visibleAgents.length, +- skillDiagnostics: workspace.skillDiagnostics.length, ++ skillDiagnostics: compact ? 0 : workspace.skillDiagnostics.length, + }, + }, + }, +- structuredContent: { +- workspaceId: workspace.id, +- root: workspace.root, +- mode: workspace.mode, +- sourceRoot: workspace.sourceRoot, +- worktree: workspace.worktree, +- agentsFiles: loadedAgentsFiles, +- availableAgentsFiles: availableAgentsFileOutputs, +- skills: visibleSkills, +- agentProviders: visibleAgentProviders, +- agents: visibleAgents, +- skillDiagnostics: workspace.skillDiagnostics, +- instruction, +- }, ++ structuredContent, + }; + }, + ); +@@ -954,6 +1056,19 @@ function createMcpServer( + offset: input.offset ?? 1, + limited: input.limit !== undefined, + }; ++ const observedChars = textContentChars(response.content); ++ const savedChars = input.offset !== undefined || input.limit !== undefined ++ ? Math.max(0, estimateFileChars(readPath.absolutePath) - observedChars) ++ : 0; ++ const usage = recordObservedToolUsage({ ++ tool: toolNames.read, ++ workspaceId, ++ workspaceRoot: workspace.root, ++ path: input.path, ++ observedChars, ++ savedChars, ++ }); ++ const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { + tool: toolNames.read, + workspaceId, +@@ -964,17 +1079,18 @@ function createMcpServer( + + return { + ...response, ++ content, + _meta: { + tool: toolNames.read, + card: { + workspaceId, + path: input.path, + summary, +- payload: { content: response.content }, ++ payload: { content }, + }, + }, + structuredContent: { +- result: contentText(response.content), ++ result: contentText(content), + }, + }; + }, +@@ -1026,6 +1142,15 @@ function createMcpServer( + lines: contentLineCount(input.content), + characters: input.content.length, + }; ++ const usage = recordObservedToolUsage({ ++ tool: toolNames.write, ++ workspaceId, ++ workspaceRoot: workspace.root, ++ path: input.path, ++ observedChars: input.content.length + textContentChars(response.content), ++ savedChars: 0, ++ }); ++ const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { + tool: toolNames.write, + workspaceId, +@@ -1036,6 +1161,7 @@ function createMcpServer( + + return { + ...response, ++ content, + _meta: { + tool: toolNames.write, + card: { +@@ -1043,13 +1169,13 @@ function createMcpServer( + path: input.path, + summary, + payload: { +- content: response.content, ++ content, + patch, + }, + }, + }, + structuredContent: { +- result: contentText(response.content), ++ result: contentText(content), + }, + }; + }, +@@ -1091,7 +1217,7 @@ function createMcpServer( + async ({ workspaceId, ...input }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); +- workspaces.resolvePath(workspace, input.path); ++ const targetPath = workspaces.resolvePath(workspace, input.path); + const response = await editFileTool(input, { + cwd: workspace.root, + root: workspace.root, +@@ -1115,6 +1241,17 @@ function createMcpServer( + }; + const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`; + const editContent = [textBlock(editResultText)]; ++ const observedChars = editInputChars(input.edits) + textContentChars(editContent); ++ const savedChars = Math.max(0, estimateFileChars(targetPath) * 2 - observedChars); ++ const usage = recordObservedToolUsage({ ++ tool: toolNames.edit, ++ workspaceId, ++ workspaceRoot: workspace.root, ++ path: input.path, ++ observedChars, ++ savedChars, ++ }); ++ const content = appendUsageToContent(editContent, usage, config.usageContent); + logToolCall(config, { + tool: toolNames.edit, + workspaceId, +@@ -1124,7 +1261,7 @@ function createMcpServer( + }); + + return { +- content: editContent, ++ content, + _meta: { + tool: toolNames.edit, + card: { +@@ -1139,7 +1276,7 @@ function createMcpServer( + }, + structuredContent: { + status: "applied", +- result: contentText(editContent), ++ result: contentText(content), + }, + }; + }, +@@ -1324,6 +1461,19 @@ function createMcpServer( + scope: input.path ?? ".", + ...textSummary(response.content), + }; ++ const usage = recordObservedToolUsage({ ++ tool: toolNames.grep, ++ workspaceId, ++ workspaceRoot: workspace.root, ++ path: input.path, ++ observedChars: ++ String(input.pattern ?? "").length ++ + String(input.path ?? "").length ++ + String(input.include ?? "").length ++ + textContentChars(response.content), ++ savedChars: 0, ++ }); ++ const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { + tool: toolNames.grep, + workspaceId, +@@ -1334,17 +1484,18 @@ function createMcpServer( + + return { + ...response, ++ content, + _meta: { + tool: toolNames.grep, + card: { + workspaceId, + path: input.path, + summary, +- payload: { content: response.content }, ++ payload: { content }, + }, + }, + structuredContent: { +- result: contentText(response.content), ++ result: contentText(content), + }, + }; + }, +@@ -1394,6 +1545,18 @@ function createMcpServer( + scope: input.path ?? ".", + ...textSummary(response.content), + }; ++ const usage = recordObservedToolUsage({ ++ tool: toolNames.glob, ++ workspaceId, ++ workspaceRoot: workspace.root, ++ path: input.path, ++ observedChars: ++ String(input.pattern ?? "").length ++ + String(input.path ?? "").length ++ + textContentChars(response.content), ++ savedChars: 0, ++ }); ++ const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { + tool: toolNames.glob, + workspaceId, +@@ -1404,17 +1567,18 @@ function createMcpServer( + + return { + ...response, ++ content, + _meta: { + tool: toolNames.glob, + card: { + workspaceId, + path: input.path, + summary, +- payload: { content: response.content }, ++ payload: { content }, + }, + }, + structuredContent: { +- result: contentText(response.content), ++ result: contentText(content), + }, + }; + }, +@@ -1460,6 +1624,15 @@ function createMcpServer( + } + + const summary = textSummary(response.content); ++ const usage = recordObservedToolUsage({ ++ tool: toolNames.ls, ++ workspaceId, ++ workspaceRoot: workspace.root, ++ path: input.path, ++ observedChars: String(input.path ?? "").length + textContentChars(response.content), ++ savedChars: 0, ++ }); ++ const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { + tool: toolNames.ls, + workspaceId, +@@ -1470,17 +1643,18 @@ function createMcpServer( + + return { + ...response, ++ content, + _meta: { + tool: toolNames.ls, + card: { + workspaceId, + path: input.path, + summary, +- payload: { content: response.content }, ++ payload: { content }, + }, + }, + structuredContent: { +- result: contentText(response.content), ++ result: contentText(content), + }, + }; + }, +@@ -1550,6 +1724,15 @@ function createMcpServer( + workingDirectory: workingDirectory ?? ".", + ...textSummary(response.content), + }; ++ const usage = recordObservedToolUsage({ ++ tool: toolNames.shell, ++ workspaceId, ++ workspaceRoot: workspace.root, ++ path: workingDirectory ?? ".", ++ observedChars: input.command.length + textContentChars(response.content), ++ savedChars: 0, ++ }); ++ const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { + tool: toolNames.shell, + workspaceId, +@@ -1562,17 +1745,18 @@ function createMcpServer( + + return { + ...response, ++ content, + _meta: { + tool: toolNames.shell, + card: { + workspaceId, + path: workingDirectory, + summary, +- payload: { content: response.content }, ++ payload: { content }, + }, + }, + structuredContent: { +- result: contentText(response.content), ++ result: contentText(content), + }, + }; + }, +@@ -1583,6 +1767,8 @@ function createMcpServer( + registerCodexProcessTools(server, config, workspaces, processSessions); + } + ++ registerV11Tools(server, { config, workspaces, localAgentProviders }); ++ + return server; + } + +diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts +index 87f6b35..d28d81b 100644 +--- a/src/workspaces.test.ts ++++ b/src/workspaces.test.ts +@@ -11,10 +11,9 @@ import { WorkspaceRegistry } from "./workspaces.js"; + + const execFileAsync = promisify(execFile); + const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); ++const agentDir = await mkdtemp(join(tmpdir(), "devspace-agent-dir-test-")); + + try { +- const agentDir = join(root, ".pi", "agent"); +- await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); + await writeFile(join(root, "AGENTS.md"), "root instructions\n"); + await mkdir(join(root, ".devspace", "agents"), { recursive: true }); +@@ -56,12 +55,22 @@ try { + availableAgentsFiles.map((file) => file.path), + [join(root, "nested", "AGENTS.md")], + ); ++ const advertisedGlobalInstruction = registry.resolveReadPath( ++ workspace, ++ join(agentDir, "AGENTS.md"), ++ ); ++ assert.equal(advertisedGlobalInstruction.absolutePath, join(agentDir, "AGENTS.md")); ++ assert.throws( ++ () => registry.resolveReadPath(workspace, join(agentDir, "not-advertised.txt")), ++ /outside allowed roots/, ++ ); + assert.deepEqual( + workspace.agentProfiles.map((profile) => ({ + name: profile.name, + description: profile.description, + provider: profile.provider, + body: profile.body, ++ writeMode: profile.writeMode, + })), + [ + { +@@ -69,6 +78,7 @@ try { + description: "Read-only project reviewer.", + provider: "codex", + body: "Review only.", ++ writeMode: "allowed", + }, + ], + ); +@@ -155,6 +165,7 @@ try { + } + } finally { + await rm(root, { recursive: true, force: true }); ++ await rm(agentDir, { recursive: true, force: true }); + } + + async function git(cwd: string, args: string[]): Promise { +diff --git a/src/workspaces.ts b/src/workspaces.ts +index 673d082..5c43f1d 100644 +--- a/src/workspaces.ts ++++ b/src/workspaces.ts +@@ -5,7 +5,7 @@ import { dirname, join, relative, resolve, sep } from "node:path"; + import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; + import type { ServerConfig } from "./config.js"; + import { createManagedWorktree } from "./git-worktrees.js"; +-import { assertAllowedPath, isPathInsideRoot, resolveAllowedPath } from "./roots.js"; ++import { assertAllowedPath, expandHomePath, isPathInsideRoot, resolveAllowedPath } from "./roots.js"; + import { + loadWorkspaceSkills, + markSkillActivated, +@@ -46,6 +46,7 @@ export interface Workspace { + skillDiagnostics: LoadedSkills["diagnostics"]; + agentProfiles: LocalAgentProfile[]; + activatedSkillDirs: Set; ++ advertisedInstructionPaths: Set; + } + + export interface WorkspaceContext { +@@ -117,6 +118,9 @@ export class WorkspaceRegistry { + ...this.loadSkillsForWorkspace(root), + agentProfiles: [], + activatedSkillDirs: new Set(), ++ advertisedInstructionPaths: new Set( ++ this.loadInitialAgentsFiles(root).map((file) => resolve(file.path)), ++ ), + }; + this.store?.touchSession(workspaceId); + this.workspaces.set(restoredWorkspace.id, restoredWorkspace); +@@ -140,6 +144,14 @@ export class WorkspaceRegistry { + readRoots: [workspace.root], + }; + } catch (workspaceError) { ++ const advertisedInstructionPath = resolve(expandHomePath(inputPath)); ++ if (workspace.advertisedInstructionPaths.has(advertisedInstructionPath)) { ++ return { ++ absolutePath: advertisedInstructionPath, ++ readRoots: [dirname(advertisedInstructionPath)], ++ }; ++ } ++ + const skillRead = resolveSkillReadPath( + workspace.skills, + workspace.activatedSkillDirs, +@@ -208,6 +220,7 @@ export class WorkspaceRegistry { + ...this.loadSkillsForWorkspace(input.root), + agentProfiles: await loadLocalAgentProfiles(this.config, input.root), + activatedSkillDirs: new Set(), ++ advertisedInstructionPaths: new Set(), + }; + + this.store?.createSession({ +@@ -222,6 +235,9 @@ export class WorkspaceRegistry { + this.workspaces.set(workspace.id, workspace); + const agentsFiles = this.loadInitialAgentsFiles(workspace.root); + const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); ++ workspace.advertisedInstructionPaths = new Set( ++ [...agentsFiles, ...availableAgentsFiles].map((file) => resolve(file.path)), ++ ); + + return { workspace, agentsFiles, availableAgentsFiles }; + } +diff --git a/src/usage-meter.ts b/src/usage-meter.ts +new file mode 100644 +index 0000000..83f6b84 +--- /dev/null ++++ b/src/usage-meter.ts +@@ -0,0 +1,176 @@ ++import { statSync } from "node:fs"; ++import { appendFile, mkdir } from "node:fs/promises"; ++import { homedir } from "node:os"; ++import { dirname, join } from "node:path"; ++import type { UsageContentMode } from "./config.js"; ++ ++type TextContent = { type: "text"; text: string }; ++type ToolContent = TextContent | { type: "image"; data: string; mimeType: string }; ++ ++interface UsageBucket { ++ observedTokens: number; ++ savedTokens: number; ++ calls: number; ++} ++ ++export interface UsageEntry { ++ ts: string; ++ sessionPid: number; ++ workspaceId?: string; ++ workspaceRoot?: string; ++ workspaceName?: string; ++ tool: string; ++ path?: string; ++ observedChars: number; ++ observedTokens: number; ++ savedChars: number; ++ savedTokens: number; ++ sessionObservedTokens: number; ++ sessionSavedTokens: number; ++ byTool: Record; ++ note: string; ++} ++ ++const session = { ++ observedTokens: 0, ++ savedTokens: 0, ++ byTool: new Map(), ++}; ++let historyWrite = Promise.resolve(); ++ ++function historyPath(): string { ++ return process.env.DEVSPACE_USAGE_HISTORY ++ ?? join(homedir(), ".local", "share", "devspace", "usage-history.jsonl"); ++} ++ ++function clampNumber(value: number): number { ++ return Number.isFinite(value) && value > 0 ? Math.ceil(value) : 0; ++} ++ ++export function estimateTokensFromChars(chars: number): number { ++ return Math.ceil(clampNumber(chars) / 4); ++} ++ ++export function textContentChars(content: ToolContent[]): number { ++ return content ++ .filter((item): item is TextContent => item?.type === "text" && typeof item.text === "string") ++ .reduce((total, item) => total + item.text.length, 0); ++} ++ ++export function estimateFileChars(path: string): number { ++ try { ++ const stats = statSync(path); ++ return stats.isFile() ? stats.size : 0; ++ } catch { ++ return 0; ++ } ++} ++ ++export function editInputChars(edits: Array<{ oldText?: string; newText?: string }>): number { ++ return edits.reduce( ++ (total, edit) => ++ total + String(edit.oldText ?? "").length + String(edit.newText ?? "").length, ++ 0, ++ ); ++} ++ ++export function compactTokenCount(tokens: number): string { ++ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(2)}M`; ++ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; ++ return `${tokens}`; ++} ++ ++function byToolSnapshot(): Record { ++ return Object.fromEntries( ++ Array.from(session.byTool.entries()).sort(([a], [b]) => a.localeCompare(b)), ++ ); ++} ++ ++function appendHistory(entry: UsageEntry): void { ++ const file = historyPath(); ++ historyWrite = historyWrite.then(async () => { ++ await mkdir(dirname(file), { recursive: true }); ++ await appendFile(file, `${JSON.stringify(entry)}\n`, "utf8"); ++ }).catch(() => { ++ // Diagnostic only. Never break the actual tool result. ++ }); ++} ++ ++export function recordObservedToolUsage(input: { ++ tool?: string; ++ workspaceId?: string; ++ workspaceRoot?: string; ++ path?: string; ++ observedChars: number; ++ savedChars: number; ++}): UsageEntry { ++ const tool = input.tool ?? "unknown"; ++ const observedChars = clampNumber(input.observedChars); ++ const savedChars = clampNumber(input.savedChars); ++ const observedTokens = estimateTokensFromChars(observedChars); ++ const savedTokens = estimateTokensFromChars(savedChars); ++ const current = session.byTool.get(tool) ?? { ++ observedTokens: 0, ++ savedTokens: 0, ++ calls: 0, ++ }; ++ ++ current.observedTokens += observedTokens; ++ current.savedTokens += savedTokens; ++ current.calls += 1; ++ session.byTool.set(tool, current); ++ session.observedTokens += observedTokens; ++ session.savedTokens += savedTokens; ++ ++ const entry: UsageEntry = { ++ ts: new Date().toISOString(), ++ sessionPid: process.pid, ++ workspaceId: input.workspaceId, ++ workspaceRoot: input.workspaceRoot, ++ workspaceName: input.workspaceRoot ++ ? String(input.workspaceRoot).split("/").filter(Boolean).at(-1) ++ : undefined, ++ tool, ++ path: input.path, ++ observedChars, ++ observedTokens, ++ savedChars, ++ savedTokens, ++ sessionObservedTokens: session.observedTokens, ++ sessionSavedTokens: session.savedTokens, ++ byTool: byToolSnapshot(), ++ note: "DevSpace observed text estimate only. Not ChatGPT actual model usage.", ++ }; ++ appendHistory(entry); ++ return entry; ++} ++ ++function fullUsageSummaryText(entry: UsageEntry): string { ++ const byTool = Object.entries(entry.byTool) ++ .map(([tool, value]) => `${tool} ${compactTokenCount(value.observedTokens)}`) ++ .join(" / "); ++ ++ return [ ++ "DevSpace token estimate:", ++ `Observed this call: ~${compactTokenCount(entry.observedTokens)} / session: ~${compactTokenCount(entry.sessionObservedTokens)}`, ++ `Session by tool: ${byTool || "none"}`, ++ `Estimated text avoided: ~${compactTokenCount(entry.sessionSavedTokens)} tokens`, ++ "Calculated only from text handled by DevSpace. This is not actual model usage or billing data.", ++ ].join("\n"); ++} ++ ++function compactUsageSummaryText(entry: UsageEntry): string { ++ return `DevSpace token estimate: call ~${compactTokenCount(entry.observedTokens)} / session ~${compactTokenCount(entry.sessionObservedTokens)}`; ++} ++ ++export function appendUsageToContent( ++ content: T[], ++ entry: UsageEntry, ++ mode: UsageContentMode, ++): ToolContent[] { ++ if (mode === "off") return content; ++ const text = mode === "full" ++ ? fullUsageSummaryText(entry) ++ : compactUsageSummaryText(entry); ++ return [...content, { type: "text", text }]; ++} +diff --git a/src/usage-meter.test.ts b/src/usage-meter.test.ts +new file mode 100644 +index 0000000..448dc8e +--- /dev/null ++++ b/src/usage-meter.test.ts +@@ -0,0 +1,46 @@ ++import assert from "node:assert/strict"; ++import { platform } from "node:os"; ++import { ++ appendUsageToContent, ++ compactTokenCount, ++ editInputChars, ++ estimateTokensFromChars, ++ recordObservedToolUsage, ++ textContentChars, ++} from "./usage-meter.js"; ++ ++assert.equal(estimateTokensFromChars(0), 0); ++assert.equal(estimateTokensFromChars(5), 2); ++assert.equal(compactTokenCount(999), "999"); ++assert.equal(compactTokenCount(1_500), "1.5k"); ++assert.equal(editInputChars([{ oldText: "old", newText: "new" }]), 6); ++assert.equal(textContentChars([{ type: "text", text: "hello" }]), 5); ++ ++const previousHistory = process.env.DEVSPACE_USAGE_HISTORY; ++process.env.DEVSPACE_USAGE_HISTORY = platform() === "win32" ? "NUL" : "/dev/null"; ++const usage = recordObservedToolUsage({ ++ tool: "read", ++ observedChars: 40, ++ savedChars: 80, ++}); ++const content = [{ type: "text" as const, text: "result" }]; ++ ++assert.deepEqual(appendUsageToContent(content, usage, "off"), content); ++const compactContent = appendUsageToContent(content, usage, "compact"); ++const compactText = compactContent.at(-1); ++assert.match( ++ compactText?.type === "text" ? compactText.text : "", ++ /DevSpace token estimate/, ++); ++const fullContent = appendUsageToContent(content, usage, "full"); ++const fullText = fullContent.at(-1); ++assert.match( ++ fullText?.type === "text" ? fullText.text : "", ++ /not actual model usage or billing data/, ++); ++ ++if (previousHistory === undefined) { ++ delete process.env.DEVSPACE_USAGE_HISTORY; ++} else { ++ process.env.DEVSPACE_USAGE_HISTORY = previousHistory; ++} diff --git a/compatibility-kit/openai-model-compatibility-2026-07/patches/0002-safe-tools-and-compound-inspection.patch b/compatibility-kit/openai-model-compatibility-2026-07/patches/0002-safe-tools-and-compound-inspection.patch new file mode 100644 index 00000000..84a15078 --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/patches/0002-safe-tools-and-compound-inspection.patch @@ -0,0 +1,1434 @@ +diff --git a/src/pi-tools.ts b/src/pi-tools.ts +index 238b9c5..dd72dd7 100644 +--- a/src/pi-tools.ts ++++ b/src/pi-tools.ts +@@ -1,3 +1,6 @@ ++import { readFile } from "node:fs/promises"; ++import { homedir } from "node:os"; ++import { join, resolve } from "node:path"; + import { + createBashTool, + createEditTool, +@@ -16,7 +19,7 @@ import { + type WriteToolInput, + type AgentToolResult, + } from "@earendil-works/pi-coding-agent"; +-import { resolveAllowedPath } from "./roots.js"; ++import { expandHomePath, resolveAllowedPath } from "./roots.js"; + + type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; + export type ToolResponse = { +@@ -50,6 +53,84 @@ function formatToolError(error: unknown): McpContent[] { + return [{ type: "text", text: message }]; + } + ++interface ApprovedShellCommand { ++ alias: string; ++ enabled?: boolean; ++ workspaceRoot: string; ++ workingDirectory?: string; ++ command: string; ++} ++ ++const approvedCommandPrefix = "devspace-approved "; ++function approvedCommandsPath(): string { ++ return resolve(expandHomePath( ++ process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE ++ ?? join(homedir(), ".devspace", "approved-shell-commands.json"), ++ )); ++} ++ ++async function loadApprovedShellCommands(): Promise { ++ try { ++ const raw = await readFile(approvedCommandsPath(), "utf8"); ++ const parsed = JSON.parse(raw) as { commands?: ApprovedShellCommand[] }; ++ return Array.isArray(parsed.commands) ? parsed.commands : []; ++ } catch { ++ return []; ++ } ++} ++ ++async function resolveApprovedShellAlias( ++ input: BashToolInput, ++ context: ToolContext, ++): Promise<{ input: BashToolInput; cwd: string }> { ++ const rawCommand = String(input.command ?? "").trim(); ++ if (!rawCommand.startsWith(approvedCommandPrefix)) { ++ return { input, cwd: context.cwd }; ++ } ++ ++ const alias = rawCommand.slice(approvedCommandPrefix.length).trim(); ++ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(alias)) { ++ throw new Error("Invalid approved shell command alias."); ++ } ++ ++ const commands = await loadApprovedShellCommands(); ++ const command = commands.find( ++ (entry) => entry?.enabled !== false && entry?.alias === alias, ++ ); ++ if (!command) { ++ throw new Error(`Approved shell command alias is not configured: ${alias}`); ++ } ++ ++ const configuredRoot = String(command.workspaceRoot ?? "").trim(); ++ if (!configuredRoot) { ++ throw new Error(`Approved shell command has no workspace root: ${alias}`); ++ } ++ ++ const expectedRoot = resolve(expandHomePath(configuredRoot)); ++ const actualRoot = resolve(context.root); ++ if (expectedRoot !== actualRoot) { ++ throw new Error(`Approved shell command alias is not allowed for this workspace: ${alias}`); ++ } ++ ++ const approvedCwd = resolveAllowedPath( ++ String(command.workingDirectory ?? "."), ++ actualRoot, ++ [actualRoot], ++ ); ++ const approvedCommand = String(command.command ?? "").trim(); ++ if (!approvedCommand) { ++ throw new Error(`Approved shell command is empty: ${alias}`); ++ } ++ ++ return { ++ input: { ++ ...input, ++ command: approvedCommand, ++ }, ++ cwd: approvedCwd, ++ }; ++} ++ + async function runTool( + execute: (input: TInput) => Promise>, + input: TInput, +@@ -119,11 +200,21 @@ export async function listDirectoryTool(input: LsToolInput, context: ToolContext + } + + export async function runShellTool(input: BashToolInput, context: ToolContext): Promise { +- const tool = createBashTool(context.cwd); +- const timeout = input.timeout === undefined ? 30 : Math.min(input.timeout, 300); ++ let approved: { input: BashToolInput; cwd: string }; ++ try { ++ approved = await resolveApprovedShellAlias(input, context); ++ } catch (error) { ++ return { content: formatToolError(error), isError: true }; ++ } ++ ++ const tool = createBashTool(approved.cwd); ++ const timeout = ++ approved.input.timeout === undefined ++ ? 30 ++ : Math.min(approved.input.timeout, 300); + + return runTool((params) => tool.execute("run_shell", params), { +- command: input.command, ++ command: approved.input.command, + timeout, + }, context); + } +diff --git a/src/pi-tools.test.ts b/src/pi-tools.test.ts +new file mode 100644 +index 0000000..55f1a85 +--- /dev/null ++++ b/src/pi-tools.test.ts +@@ -0,0 +1,51 @@ ++import assert from "node:assert/strict"; ++import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import { runShellTool } from "./pi-tools.js"; ++ ++const root = await mkdtemp(join(tmpdir(), "devspace-approved-shell-test-")); ++const commandsFile = join(root, "approved.json"); ++const previousCommandsFile = process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE; ++ ++try { ++ await mkdir(join(root, "nested")); ++ process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE = commandsFile; ++ await writeFile(commandsFile, JSON.stringify({ ++ commands: [{ ++ alias: "where", ++ workspaceRoot: root, ++ workingDirectory: "nested", ++ command: "pwd", ++ }], ++ })); ++ ++ const allowed = await runShellTool( ++ { command: "devspace-approved where" }, ++ { cwd: root, root }, ++ ); ++ assert.equal(allowed.isError, undefined); ++ assert.match(allowed.content[0]?.type === "text" ? allowed.content[0].text : "", /nested/); ++ ++ await writeFile(commandsFile, JSON.stringify({ ++ commands: [{ ++ alias: "escape", ++ workspaceRoot: root, ++ workingDirectory: "..", ++ command: "pwd", ++ }], ++ })); ++ const denied = await runShellTool( ++ { command: "devspace-approved escape" }, ++ { cwd: root, root }, ++ ); ++ assert.equal(denied.isError, true); ++ assert.match(denied.content[0]?.type === "text" ? denied.content[0].text : "", /outside allowed roots/); ++} finally { ++ if (previousCommandsFile === undefined) { ++ delete process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE; ++ } else { ++ process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE = previousCommandsFile; ++ } ++ await rm(root, { recursive: true, force: true }); ++} +diff --git a/src/safe-inspection.ts b/src/safe-inspection.ts +new file mode 100644 +index 0000000..20383be +--- /dev/null ++++ b/src/safe-inspection.ts +@@ -0,0 +1,66 @@ ++import { realpath, stat } from "node:fs/promises"; ++import { basename, extname, resolve, sep } from "node:path"; ++import { isPathInsideRoot } from "./roots.js"; ++ ++const SECRET_SEGMENTS = new Set([ ++ ".aws", ++ ".gnupg", ++ ".ssh", ++ "credentials", ++ "credential", ++ "secrets", ++ "secret", ++]); ++const SECRET_EXTENSIONS = new Set([".key", ".pem", ".p12", ".pfx"]); ++const BINARY_EXTENSIONS = new Set([ ++ ".7z", ".avi", ".bin", ".bmp", ".class", ".dmg", ".doc", ".docx", ++ ".gif", ".gz", ".ico", ".jar", ".jpeg", ".jpg", ".mov", ".mp3", ++ ".mp4", ".pdf", ".png", ".tar", ".wasm", ".webp", ".woff", ".woff2", ".zip", ++]); ++const GENERATED_SEGMENTS = new Set([ ++ ".git", ".next", ".turbo", "build", "coverage", "dist", "node_modules", ++]); ++ ++export function pathSegments(path: string): string[] { ++ return resolve(path).split(sep).filter(Boolean).map((part) => part.toLowerCase()); ++} ++ ++export function isSecretLikePath(path: string): boolean { ++ const segments = pathSegments(path); ++ const name = basename(path).toLowerCase(); ++ return segments.some((part) => SECRET_SEGMENTS.has(part)) ++ || name === ".env" ++ || name.startsWith(".env.") ++ || name === "id_rsa" ++ || name === "id_ed25519" ++ || /(?:^|[-_.])(token|credentials?|secrets?)(?:[-_.]|$)/.test(name) ++ || SECRET_EXTENSIONS.has(extname(name)); ++} ++ ++export function isGeneratedOrBinaryPath(path: string): boolean { ++ const segments = pathSegments(path); ++ return segments.some((part) => GENERATED_SEGMENTS.has(part)) ++ || BINARY_EXTENSIONS.has(extname(path).toLowerCase()); ++} ++ ++export function containsSecretValue(text: string): boolean { ++ return /-----BEGIN [A-Z ]*PRIVATE KEY-----/i.test(text) ++ || /\b(?:bearer|api[_-]?key|access[_-]?token|client[_-]?secret)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{12,}/i.test(text); ++} ++ ++export function redactSensitiveText(text: string): string { ++ return text ++ .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi, "[REDACTED PRIVATE KEY]") ++ .replace(/(\b(?:bearer|api[_-]?key|access[_-]?token|client[_-]?secret)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]"); ++} ++ ++export async function safeRealFile(path: string, root: string): Promise { ++ try { ++ const [realFile, realRoot] = await Promise.all([realpath(path), realpath(root)]); ++ if (!isPathInsideRoot(realFile, realRoot)) return undefined; ++ if (!(await stat(realFile)).isFile()) return undefined; ++ return realFile; ++ } catch { ++ return undefined; ++ } ++} +diff --git a/src/compound-tools.ts b/src/compound-tools.ts +new file mode 100644 +index 0000000..dc2f24c +--- /dev/null ++++ b/src/compound-tools.ts +@@ -0,0 +1,592 @@ ++import { execFile } from "node:child_process"; ++import { existsSync, realpathSync } from "node:fs"; ++import { opendir, readFile, realpath, stat } from "node:fs/promises"; ++import { basename, dirname, relative, resolve, sep } from "node:path"; ++import { promisify } from "node:util"; ++import { isPathInsideRoot, resolveAllowedPath } from "./roots.js"; ++import { ++ containsSecretValue, ++ isGeneratedOrBinaryPath, ++ isSecretLikePath, ++ safeRealFile, ++} from "./safe-inspection.js"; ++import { ++ buildBoundedPayload, ++ clampInteger, ++ takeWithinCharacterBudget, ++ type ToolMetrics, ++} from "./tool-metrics.js"; ++ ++const execFileAsync = promisify(execFile); ++const MAX_GIT_BUFFER = 1024 * 1024; ++const SKIPPED_DIRS = new Set([ ++ ".git", ".next", ".turbo", "build", "coverage", "dist", "node_modules", ++]); ++ ++export interface InspectionSkillSummary { ++ name: string; ++ description?: string; ++} ++ ++export interface InspectionAgentSummary { ++ name: string; ++ provider: string; ++ available?: boolean; ++ writeMode?: string; ++} ++ ++export interface WorkspaceInspectionContext { ++ root: string; ++ instructionPaths: string[]; ++ skills: InspectionSkillSummary[]; ++ agentProviders: InspectionAgentSummary[]; ++ agentProfiles: InspectionAgentSummary[]; ++} ++ ++export interface ProjectSnapshotResult extends Record { ++ branch: string | null; ++ dirty: boolean; ++ changedFiles: string[]; ++ diffStat: string; ++ package: { ++ name?: string; ++ version?: string; ++ scripts: string[]; ++ }; ++ applicableInstructions: string[]; ++ skills: InspectionSkillSummary[]; ++ agentProviders: InspectionAgentSummary[]; ++ agentProfiles: InspectionAgentSummary[]; ++ codeGraph: { ++ detected: boolean; ++ available: false; ++ reason: "not_initialized" | "adapter_unavailable"; ++ }; ++ recommendedTestCommand?: string; ++ recommendedBuildCommand?: string; ++ metrics: ToolMetrics; ++} ++ ++export async function projectSnapshot( ++ context: WorkspaceInspectionContext, ++ options: { maxCharacters?: number } = {}, ++): Promise { ++ const startedAt = performance.now(); ++ const maxCharacters = clampInteger(options.maxCharacters, 12_000, 2_000, 50_000); ++ const [branchResult, statusResult, statResult, packageInfo] = await Promise.all([ ++ git(context.root, ["symbolic-ref", "--quiet", "--short", "HEAD"]), ++ git(context.root, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]), ++ git(context.root, ["diff", "--no-ext-diff", "--no-textconv", "--shortstat", "HEAD", "--"]), ++ readPackageSummary(context.root), ++ ]); ++ const rawChangedFiles = statusResult.ok ? parseStatusPaths(statusResult.stdout) : []; ++ const detected = existsSync(resolve(context.root, ".codegraph")); ++ ++ return buildBoundedPayload({ ++ startedAt, ++ maxCharacters, ++ build: (contentBudget) => { ++ const files = takeWithinCharacterBudget(rawChangedFiles, Math.floor(contentBudget * 0.28)); ++ const instructions = takeWithinCharacterBudget( ++ context.instructionPaths.map((path) => displayPath(path, context.root)), ++ Math.floor(contentBudget * 0.18), ++ ); ++ const skills = takeWithinCharacterBudget( ++ context.skills.map((skill) => ({ ++ name: skill.name.slice(0, 100), ++ ...(skill.description ? { description: skill.description.slice(0, 160) } : {}), ++ })), ++ Math.floor(contentBudget * 0.18), ++ ); ++ const providers = takeWithinCharacterBudget( ++ context.agentProviders, ++ Math.floor(contentBudget * 0.13), ++ ); ++ const profiles = takeWithinCharacterBudget( ++ context.agentProfiles, ++ Math.floor(contentBudget * 0.13), ++ ); ++ const truncated = files.truncated ++ || instructions.truncated ++ || skills.truncated ++ || providers.truncated ++ || profiles.truncated; ++ const returnedItems = files.items.length ++ + instructions.items.length ++ + skills.items.length ++ + providers.items.length ++ + profiles.items.length; ++ ++ return { ++ payload: { ++ branch: branchResult.ok ? branchResult.stdout.trim() || null : null, ++ dirty: rawChangedFiles.length > 0, ++ changedFiles: files.items, ++ diffStat: statResult.ok ? statResult.stdout.trim().slice(0, 500) : "", ++ package: packageInfo, ++ applicableInstructions: instructions.items, ++ skills: skills.items, ++ agentProviders: providers.items, ++ agentProfiles: profiles.items, ++ codeGraph: { ++ detected, ++ available: false as const, ++ reason: detected ? "adapter_unavailable" as const : "not_initialized" as const, ++ }, ++ ...(recommendedCommand(packageInfo.scripts, "test") ++ ? { recommendedTestCommand: recommendedCommand(packageInfo.scripts, "test") } ++ : {}), ++ ...(recommendedCommand(packageInfo.scripts, "build") ++ ? { recommendedBuildCommand: recommendedCommand(packageInfo.scripts, "build") } ++ : {}), ++ }, ++ returnedItems, ++ truncated, ++ }; ++ }, ++ }); ++} ++ ++export interface FocusedContextResult extends Record { ++ relevantFiles: string[]; ++ relevantSymbols: Array<{ name: string; path: string; line: number }>; ++ searchMatches: Array<{ path: string; line: number }>; ++ applicableInstructions: string[]; ++ impactCandidates: string[]; ++ recommendedReads: string[]; ++ detectionMethod: "bounded_text_fallback" | "codegraph_adapter_unavailable_fallback"; ++ metrics: ToolMetrics; ++} ++ ++export async function focusedContext( ++ context: WorkspaceInspectionContext, ++ options: { ++ focus: string; ++ paths?: string[]; ++ maxFiles?: number; ++ maxCharacters?: number; ++ }, ++): Promise { ++ const startedAt = performance.now(); ++ const focus = options.focus.normalize("NFKC").trim().slice(0, 300); ++ if (!focus) throw new Error("focus is required."); ++ const maxFiles = clampInteger(options.maxFiles, 8, 1, 25); ++ const maxCharacters = clampInteger(options.maxCharacters, 12_000, 2_000, 50_000); ++ const scopes = await resolveScopes(context.root, options.paths); ++ const candidates = await collectCandidateFiles(context.root, scopes, 500); ++ const focusTokens = searchableTokens(focus); ++ candidates.files.sort((a, b) => filenameScore(b, focusTokens) - filenameScore(a, focusTokens)); ++ ++ const relevantFiles: string[] = []; ++ const relevantSymbols: Array<{ name: string; path: string; line: number }> = []; ++ const searchMatches: Array<{ path: string; line: number }> = []; ++ let scannedBytes = 0; ++ let scanTruncated = candidates.truncated || candidates.files.length > 120; ++ ++ for (const path of candidates.files.slice(0, 120)) { ++ if (relevantFiles.length >= maxFiles || scannedBytes >= 3_000_000) { ++ scanTruncated = true; ++ break; ++ } ++ const content = await readBoundedTextFile(path, context.root, 96_000); ++ if (content === undefined) continue; ++ scannedBytes += content.length; ++ const display = displayPath(path, context.root); ++ const lines = content.split(/\r?\n/); ++ let matchedFile = false; ++ for (let index = 0; index < lines.length; index += 1) { ++ const line = lines[index] ?? ""; ++ const normalizedLine = line.normalize("NFKC").toLocaleLowerCase(); ++ if (!focusTokens.some((token) => normalizedLine.includes(token))) continue; ++ matchedFile = true; ++ if (searchMatches.length < maxFiles * 5) { ++ searchMatches.push({ path: display, line: index + 1 }); ++ } ++ const symbol = extractSymbol(line); ++ if (symbol && relevantSymbols.length < maxFiles * 3) { ++ relevantSymbols.push({ name: symbol, path: display, line: index + 1 }); ++ } ++ } ++ if (matchedFile) relevantFiles.push(display); ++ } ++ ++ const instructionPaths = context.instructionPaths ++ .filter((path) => scopes.some((scope) => ++ isPathInsideRoot(path, scope) || isPathInsideRoot(scope, dirname(path)))) ++ .map((path) => displayPath(path, context.root)); ++ const codeGraphDetected = existsSync(resolve(context.root, ".codegraph")); ++ ++ return buildBoundedPayload({ ++ startedAt, ++ maxCharacters, ++ build: (contentBudget) => { ++ const files = takeWithinCharacterBudget(relevantFiles, Math.floor(contentBudget * 0.18)); ++ const symbols = takeWithinCharacterBudget(relevantSymbols, Math.floor(contentBudget * 0.22)); ++ const matches = takeWithinCharacterBudget(searchMatches, Math.floor(contentBudget * 0.22)); ++ const instructions = takeWithinCharacterBudget(instructionPaths, Math.floor(contentBudget * 0.13)); ++ const impact = takeWithinCharacterBudget(relevantFiles, Math.floor(contentBudget * 0.1)); ++ const reads = takeWithinCharacterBudget(relevantFiles, Math.floor(contentBudget * 0.1)); ++ const truncated = scanTruncated ++ || files.truncated || symbols.truncated || matches.truncated ++ || instructions.truncated || impact.truncated || reads.truncated; ++ return { ++ payload: { ++ relevantFiles: files.items, ++ relevantSymbols: symbols.items, ++ searchMatches: matches.items, ++ applicableInstructions: instructions.items, ++ impactCandidates: impact.items, ++ recommendedReads: reads.items, ++ detectionMethod: codeGraphDetected ++ ? "codegraph_adapter_unavailable_fallback" as const ++ : "bounded_text_fallback" as const, ++ }, ++ returnedItems: files.items.length + symbols.items.length + matches.items.length, ++ truncated, ++ }; ++ }, ++ }); ++} ++ ++export interface ReviewChangesResult extends Record { ++ changedFiles: Array<{ path: string; status: string }>; ++ diffStat: string; ++ summary: string; ++ riskCandidates: string[]; ++ suspiciousChanges: Array<{ rule: string; file: string; line?: number }>; ++ testRecommendations: string[]; ++ truncated: boolean; ++ metrics: ToolMetrics; ++} ++ ++export async function reviewChanges( ++ context: WorkspaceInspectionContext, ++ options: { scope?: string; baseRef?: string; maxCharacters?: number } = {}, ++): Promise { ++ const startedAt = performance.now(); ++ const maxCharacters = clampInteger(options.maxCharacters, 12_000, 2_000, 50_000); ++ const baseRef = options.baseRef?.trim() || "HEAD"; ++ validateBaseRef(baseRef); ++ await requireGitRef(context.root, baseRef); ++ const pathspec = options.scope ? await gitPathspec(context.root, options.scope) : undefined; ++ const suffix = pathspec ? ["--", pathspec] : ["--"]; ++ const [nameStatus, status, diffStat, diff, packageInfo] = await Promise.all([ ++ git(context.root, ["diff", "--no-ext-diff", "--no-textconv", "--name-status", "-z", baseRef, ...suffix]), ++ git(context.root, ["status", "--porcelain=v1", "-z", "--untracked-files=normal", ...suffix]), ++ git(context.root, ["diff", "--no-ext-diff", "--no-textconv", "--shortstat", baseRef, ...suffix]), ++ git(context.root, ["diff", "--no-ext-diff", "--no-textconv", "--unified=0", baseRef, ...suffix]), ++ readPackageSummary(context.root), ++ ]); ++ const changed = new Map(); ++ if (nameStatus.ok) { ++ for (const entry of parseNameStatus(nameStatus.stdout)) changed.set(entry.path, entry.status); ++ } ++ if (status.ok) { ++ for (const path of parseStatusPaths(status.stdout)) { ++ if (!changed.has(path)) changed.set(path, "untracked"); ++ } ++ } ++ const changedFiles = Array.from(changed, ([path, changeStatus]) => ({ path, status: changeStatus })) ++ .sort((a, b) => a.path.localeCompare(b.path)); ++ const suspicious = diff.ok ? inspectDiff(diff.stdout.slice(0, 300_000)) : []; ++ const riskCandidates = Array.from(new Set(suspicious.map((item) => item.rule))); ++ const testRecommendations = ["typecheck", "test", "build"] ++ .map((name) => recommendedCommand(packageInfo.scripts, name)) ++ .filter((command): command is string => command !== undefined); ++ ++ const result = buildBoundedPayload({ ++ startedAt, ++ maxCharacters, ++ build: (contentBudget) => { ++ const files = takeWithinCharacterBudget(changedFiles, Math.floor(contentBudget * 0.4)); ++ const risks = takeWithinCharacterBudget(riskCandidates, Math.floor(contentBudget * 0.15)); ++ const findings = takeWithinCharacterBudget(suspicious, Math.floor(contentBudget * 0.3)); ++ const tests = takeWithinCharacterBudget(testRecommendations, Math.floor(contentBudget * 0.1)); ++ const truncated = files.truncated || risks.truncated || findings.truncated || tests.truncated ++ || (diff.ok && diff.stdout.length > 300_000); ++ return { ++ payload: { ++ changedFiles: files.items, ++ diffStat: diffStat.ok ? diffStat.stdout.trim().slice(0, 500) : "", ++ summary: `${changedFiles.length} changed file(s); ${suspicious.length} suspicious pattern(s).`, ++ riskCandidates: risks.items, ++ suspiciousChanges: findings.items, ++ testRecommendations: tests.items, ++ truncated, ++ }, ++ returnedItems: files.items.length + findings.items.length, ++ truncated, ++ }; ++ }, ++ }); ++ ++ return { ...result, truncated: result.metrics.truncated }; ++} ++ ++async function readPackageSummary(root: string): Promise<{ ++ name?: string; ++ version?: string; ++ scripts: string[]; ++}> { ++ const packagePath = resolve(root, "package.json"); ++ const realFile = await safeRealFile(packagePath, root); ++ if (!realFile || isSecretLikePath(realFile)) return { scripts: [] }; ++ try { ++ const info = await stat(realFile); ++ if (info.size > 512_000) return { scripts: [] }; ++ const parsed = JSON.parse(await readFile(realFile, "utf8")) as Record; ++ const scripts = parsed.scripts && typeof parsed.scripts === "object" && !Array.isArray(parsed.scripts) ++ ? Object.keys(parsed.scripts as Record) ++ .filter((name) => /^[A-Za-z0-9][A-Za-z0-9:._-]{0,99}$/.test(name)) ++ .slice(0, 100) ++ : []; ++ const packageName = typeof parsed.name === "string" && !containsSecretValue(parsed.name) ++ ? parsed.name.slice(0, 200) ++ : undefined; ++ const packageVersion = typeof parsed.version === "string" && !containsSecretValue(parsed.version) ++ ? parsed.version.slice(0, 50) ++ : undefined; ++ return { ++ ...(packageName ? { name: packageName } : {}), ++ ...(packageVersion ? { version: packageVersion } : {}), ++ scripts, ++ }; ++ } catch { ++ return { scripts: [] }; ++ } ++} ++ ++function recommendedCommand(scripts: string[], name: string): string | undefined { ++ if (!scripts.includes(name)) return undefined; ++ return name === "test" ? "npm test" : `npm run ${name}`; ++} ++ ++async function resolveScopes(root: string, paths: string[] | undefined): Promise { ++ if (!paths || paths.length === 0) return [await realpath(root)]; ++ const scopes: string[] = []; ++ for (const path of paths.slice(0, 25)) { ++ const resolved = resolveAllowedPath(path, root, [root]); ++ const real = await realpath(resolved); ++ const realRoot = await realpath(root); ++ if (!isPathInsideRoot(real, realRoot)) throw new Error(`Path is outside workspace root: ${path}`); ++ scopes.push(real); ++ } ++ return scopes; ++} ++ ++async function collectCandidateFiles( ++ root: string, ++ scopes: string[], ++ hardLimit: number, ++): Promise<{ files: string[]; truncated: boolean }> { ++ const files: string[] = []; ++ let truncated = false; ++ const realRoot = await realpath(root); ++ ++ const visit = async (path: string): Promise => { ++ if (files.length >= hardLimit) { ++ truncated = true; ++ return; ++ } ++ let info; ++ try { ++ info = await stat(path); ++ } catch { ++ return; ++ } ++ if (info.isFile()) { ++ const realFile = await safeRealFile(path, realRoot); ++ if (!realFile || isSecretLikePath(realFile) || isGeneratedOrBinaryPath(realFile)) return; ++ files.push(realFile); ++ return; ++ } ++ if (!info.isDirectory()) return; ++ let realDirectory: string; ++ try { ++ realDirectory = await realpath(path); ++ } catch { ++ return; ++ } ++ if (!isPathInsideRoot(realDirectory, realRoot)) return; ++ let directory; ++ try { ++ directory = await opendir(path); ++ } catch { ++ return; ++ } ++ for await (const entry of directory) { ++ if (files.length >= hardLimit) { ++ truncated = true; ++ break; ++ } ++ if (entry.isDirectory() && SKIPPED_DIRS.has(entry.name)) continue; ++ await visit(resolve(path, entry.name)); ++ } ++ }; ++ ++ for (const scope of scopes) await visit(scope); ++ return { files, truncated }; ++} ++ ++async function readBoundedTextFile( ++ path: string, ++ root: string, ++ maxBytes: number, ++): Promise { ++ const realFile = await safeRealFile(path, root); ++ if (!realFile || isSecretLikePath(realFile) || isGeneratedOrBinaryPath(realFile)) return undefined; ++ try { ++ const info = await stat(realFile); ++ if (info.size > maxBytes) return undefined; ++ const content = await readFile(realFile); ++ if (content.includes(0)) return undefined; ++ return content.toString("utf8"); ++ } catch { ++ return undefined; ++ } ++} ++ ++function searchableTokens(focus: string): string[] { ++ const normalized = focus.toLocaleLowerCase(); ++ const values = normalized.match(/[\p{L}\p{N}_-]{2,}/gu) ?? []; ++ return Array.from(new Set([normalized, ...values])).filter((value) => value.length >= 2).slice(0, 12); ++} ++ ++function filenameScore(path: string, focusTokens: string[]): number { ++ const name = basename(path).toLocaleLowerCase(); ++ return focusTokens.reduce((score, token) => score + (name.includes(token) ? 1 : 0), 0); ++} ++ ++function extractSymbol(line: string): string | undefined { ++ const match = line.match(/\b(?:class|interface|type|function|const|let|var|enum)\s+([A-Za-z_$][\w$]*)/); ++ return match?.[1]; ++} ++ ++function displayPath(path: string, root: string): string { ++ const lexicalPath = resolve(path); ++ const lexicalRoot = resolve(root); ++ let comparablePath = lexicalPath; ++ let comparableRoot = lexicalRoot; ++ if (!isPathInsideRoot(lexicalPath, lexicalRoot)) { ++ try { ++ comparablePath = realpathSync(lexicalPath); ++ comparableRoot = realpathSync(lexicalRoot); ++ } catch { ++ // Keep lexical paths when either side does not exist. ++ } ++ } ++ const relationship = relative(comparableRoot, comparablePath); ++ if (relationship === "" || relationship === ".") return "."; ++ if (relationship === ".." || relationship.startsWith(`..${sep}`)) return resolve(path).split(sep).join("/"); ++ return relationship.split(sep).join("/"); ++} ++ ++function parseStatusPaths(output: string): string[] { ++ const tokens = output.split("\0").filter(Boolean); ++ const paths: string[] = []; ++ for (let index = 0; index < tokens.length; index += 1) { ++ const token = tokens[index] ?? ""; ++ if (token.length < 4) continue; ++ const status = token.slice(0, 2); ++ const path = token.slice(3); ++ if (path) paths.push(path); ++ if (status.includes("R") || status.includes("C")) index += 1; ++ } ++ return Array.from(new Set(paths)).sort(); ++} ++ ++function parseNameStatus(output: string): Array<{ path: string; status: string }> { ++ const tokens = output.split("\0").filter(Boolean); ++ const entries: Array<{ path: string; status: string }> = []; ++ for (let index = 0; index < tokens.length; index += 1) { ++ const status = tokens[index] ?? ""; ++ const path = tokens[index + 1]; ++ if (!path) break; ++ if (status.startsWith("R") || status.startsWith("C")) { ++ const destination = tokens[index + 2]; ++ if (destination) entries.push({ path: destination, status }); ++ index += 2; ++ } else { ++ entries.push({ path, status }); ++ index += 1; ++ } ++ } ++ return entries; ++} ++ ++function inspectDiff(diff: string): Array<{ rule: string; file: string; line?: number }> { ++ const findings: Array<{ rule: string; file: string; line?: number }> = []; ++ let file = "unknown"; ++ let nextLine: number | undefined; ++ for (const line of diff.split("\n")) { ++ if (line.startsWith("+++ b/")) { ++ file = line.slice(6); ++ nextLine = undefined; ++ continue; ++ } ++ const hunk = line.match(/^@@[^+]*\+(\d+)/); ++ if (hunk) { ++ nextLine = Number(hunk[1]); ++ continue; ++ } ++ if (!line.startsWith("+") || line.startsWith("+++")) continue; ++ const value = line.slice(1); ++ const add = (rule: string) => { ++ if (!findings.some((item) => item.rule === rule && item.file === file && item.line === nextLine)) { ++ findings.push({ rule, file, ...(nextLine === undefined ? {} : { line: nextLine }) }); ++ } ++ }; ++ if (containsSecretValue(value)) add("potential_secret_value"); ++ if (/\b(?:eval|exec)\s*\(/.test(value)) add("dynamic_code_execution"); ++ if (/chmod\s+777|dangerouslySetInnerHTML/.test(value)) add("dangerous_permission_or_html"); ++ if (/\bas\s+any\b|:\s*any\b/.test(value)) add("type_safety_escape"); ++ if (/oauth|permissions?|allowed[_-]?roots?|iam/i.test(file)) add("permission_boundary_changed"); ++ if (/package(?:-lock)?\.json$|pnpm-lock|yarn\.lock$/.test(file)) add("dependency_manifest_changed"); ++ if (nextLine !== undefined) nextLine += 1; ++ if (findings.length >= 100) break; ++ } ++ return findings; ++} ++ ++function validateBaseRef(baseRef: string): void { ++ if (!baseRef || baseRef.length > 200 || baseRef.startsWith("-") || /[\0-\x1f\x7f]/.test(baseRef)) { ++ throw new Error(`Invalid baseRef: ${baseRef}`); ++ } ++} ++ ++async function requireGitRef(root: string, baseRef: string): Promise { ++ const result = await git(root, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${baseRef}^{commit}`]); ++ if (!result.ok) throw new Error(`Unknown baseRef: ${baseRef}`); ++} ++ ++async function gitPathspec(root: string, scope: string): Promise { ++ const absolute = resolveAllowedPath(scope, root, [root]); ++ const realRoot = await realpath(root); ++ let realScope: string; ++ try { ++ realScope = await realpath(absolute); ++ } catch { ++ realScope = absolute; ++ } ++ if (!isPathInsideRoot(realScope, realRoot)) throw new Error(`Path is outside workspace root: ${scope}`); ++ const relationship = relative(realRoot, realScope).split(sep).join("/"); ++ return `:(literal)${relationship || "."}`; ++} ++ ++async function git(root: string, args: string[]): Promise<{ ok: boolean; stdout: string }> { ++ try { ++ const result = await execFileAsync("git", args, { ++ cwd: root, ++ encoding: "utf8", ++ timeout: 5_000, ++ maxBuffer: MAX_GIT_BUFFER, ++ windowsHide: true, ++ }); ++ return { ok: true, stdout: result.stdout }; ++ } catch (error) { ++ const stdout = typeof error === "object" && error && "stdout" in error && typeof error.stdout === "string" ++ ? error.stdout ++ : ""; ++ return { ok: false, stdout }; ++ } ++} +diff --git a/src/compound-tools.test.ts b/src/compound-tools.test.ts +new file mode 100644 +index 0000000..23a46b1 +--- /dev/null ++++ b/src/compound-tools.test.ts +@@ -0,0 +1,99 @@ ++import assert from "node:assert/strict"; ++import { execFile } from "node:child_process"; ++import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import { promisify } from "node:util"; ++import { ++ focusedContext, ++ projectSnapshot, ++ reviewChanges, ++ type WorkspaceInspectionContext, ++} from "./compound-tools.js"; ++ ++const execFileAsync = promisify(execFile); ++const root = await mkdtemp(join(tmpdir(), "gpt-agent-compound-tools-test-")); ++ ++try { ++ await mkdir(join(root, "src")); ++ await mkdir(join(root, "other")); ++ await writeFile(join(root, "package.json"), JSON.stringify({ ++ name: "fixture-project", ++ version: "1.2.3", ++ scripts: { typecheck: "tsc", test: "test-command", build: "build-command" }, ++ })); ++ await writeFile(join(root, "src", "target.ts"), "export function targetSymbol() { return 1; }\n"); ++ await writeFile(join(root, "other", "outside.ts"), "export const targetSymbol = 2;\n"); ++ await writeFile(join(root, ".env"), "API_KEY=super-secret-value-that-must-not-leak\n"); ++ await git(["init"]); ++ await git(["config", "user.email", "gpt-agent@example.com"]); ++ await git(["config", "user.name", "GPT Agent"]); ++ await git(["add", "."]); ++ await git(["commit", "-m", "fixture"]); ++ ++ await writeFile(join(root, "src", "target.ts"), [ ++ "export function targetSymbol() {", ++ " const unsafe: any = 2;", ++ " return unsafe;", ++ "}", ++ "", ++ ].join("\n")); ++ await writeFile(join(root, ".env"), "API_KEY=changed-secret-value-that-must-not-leak\n"); ++ ++ const context: WorkspaceInspectionContext = { ++ root, ++ instructionPaths: [join(root, "AGENTS.md")], ++ skills: Array.from({ length: 80 }, (_, index) => ({ ++ name: `skill-${index}`, ++ description: `Skill ${index} description`, ++ })), ++ agentProviders: [{ name: "codex", provider: "codex", available: true }], ++ agentProfiles: [{ name: "review", provider: "codex", writeMode: "read_only" }], ++ }; ++ ++ const snapshot = await projectSnapshot(context, { maxCharacters: 2_000 }); ++ assert.equal(snapshot.package.name, "fixture-project"); ++ assert.deepEqual(snapshot.package.scripts.sort(), ["build", "test", "typecheck"]); ++ assert.equal(snapshot.recommendedTestCommand, "npm test"); ++ assert.equal(snapshot.recommendedBuildCommand, "npm run build"); ++ assert.equal(snapshot.metrics.truncated, true); ++ assert.ok(snapshot.metrics.payloadCharacters <= 2_000); ++ assert.equal(snapshot.metrics.payloadCharacters, JSON.stringify(snapshot).length); ++ assert.equal(JSON.stringify(snapshot).includes("super-secret-value"), false); ++ ++ const focused = await focusedContext(context, { ++ focus: "targetSymbol", ++ paths: ["src"], ++ maxFiles: 1, ++ maxCharacters: 4_000, ++ }); ++ assert.deepEqual(focused.relevantFiles, ["src/target.ts"]); ++ assert.equal(focused.relevantFiles.some((path) => path.startsWith("other/")), false); ++ assert.equal(focused.relevantFiles.length <= 1, true); ++ assert.equal(focused.metrics.payloadCharacters, JSON.stringify(focused).length); ++ ++ const fileBefore = await readFile(join(root, "src", "target.ts")); ++ const indexBefore = await readFile(join(root, ".git", "index")); ++ const statusBefore = (await git(["status", "--porcelain=v1", "-z"])).stdout; ++ const stagedBefore = (await git(["diff", "--cached"])).stdout; ++ const review = await reviewChanges(context, { maxCharacters: 4_000 }); ++ assert.ok(review.changedFiles.some((entry) => entry.path === "src/target.ts")); ++ assert.ok(review.suspiciousChanges.some((entry) => entry.rule === "type_safety_escape")); ++ assert.equal(JSON.stringify(review).includes("changed-secret-value"), false); ++ assert.equal(review.metrics.payloadCharacters, JSON.stringify(review).length); ++ assert.deepEqual(await readFile(join(root, "src", "target.ts")), fileBefore); ++ assert.deepEqual(await readFile(join(root, ".git", "index")), indexBefore); ++ assert.equal((await git(["status", "--porcelain=v1", "-z"])).stdout, statusBefore); ++ assert.equal((await git(["diff", "--cached"])).stdout, stagedBefore); ++ ++ await assert.rejects( ++ () => reviewChanges(context, { baseRef: "--output=/tmp/escape" }), ++ /Invalid baseRef/, ++ ); ++} finally { ++ await rm(root, { recursive: true, force: true }); ++} ++ ++async function git(args: string[]): Promise<{ stdout: string; stderr: string }> { ++ return execFileAsync("git", args, { cwd: root, encoding: "utf8" }); ++} +diff --git a/src/register-v11-tools.ts b/src/register-v11-tools.ts +new file mode 100644 +index 0000000..9730a8c +--- /dev/null ++++ b/src/register-v11-tools.ts +@@ -0,0 +1,336 @@ ++import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; ++import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; ++import * as z from "zod/v4"; ++import type { ServerConfig } from "./config.js"; ++import { ++ focusedContext, ++ projectSnapshot, ++ reviewChanges, ++ type WorkspaceInspectionContext, ++} from "./compound-tools.js"; ++import { runDesignAudit } from "./design-audit.js"; ++import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; ++import { matchWorkspaceSkills } from "./skill-matcher.js"; ++import type { Workspace, WorkspaceRegistry } from "./workspaces.js"; ++ ++const metricsSchema = z.object({ ++ serverDurationMs: z.number().int().nonnegative(), ++ payloadCharacters: z.number().int().nonnegative(), ++ returnedItems: z.number().int().nonnegative(), ++ truncated: z.boolean(), ++ cacheHit: z.boolean().optional(), ++}); ++ ++const readOnlyAnnotations = { ++ readOnlyHint: true, ++ destructiveHint: false, ++ idempotentHint: true, ++ openWorldHint: false, ++}; ++ ++export function registerV11Tools( ++ server: McpServer, ++ input: { ++ config: ServerConfig; ++ workspaces: WorkspaceRegistry; ++ localAgentProviders: LocalAgentProviderAvailability[]; ++ }, ++): void { ++ const enabledTools = new Set(enabledV11ToolNames(input.config)); ++ if (enabledTools.has("match_skills")) { ++ registerAppTool( ++ server, ++ "match_skills", ++ { ++ title: "Match skills", ++ description: "Rank a small set of relevant Skill metadata without loading Skill bodies.", ++ inputSchema: { ++ workspaceId: z.string(), ++ task: z.string().min(1).max(4_000), ++ limit: z.number().int().min(1).max(10).optional(), ++ includeGlobal: z.boolean().optional(), ++ }, ++ outputSchema: { ++ matches: z.array(z.object({ ++ name: z.string(), ++ shortDescription: z.string(), ++ path: z.string(), ++ matchReason: z.string(), ++ confidence: z.number().min(0).max(1), ++ requiredTools: z.array(z.string()).optional(), ++ })), ++ metrics: metricsSchema, ++ }, ++ _meta: {}, ++ annotations: readOnlyAnnotations, ++ }, ++ async ({ workspaceId, task, limit, includeGlobal }) => { ++ const workspace = input.workspaces.getWorkspace(workspaceId); ++ const result = await matchWorkspaceSkills({ ++ skills: workspace.skills, ++ workspaceRoot: workspace.root, ++ task, ++ limit, ++ includeGlobal, ++ }); ++ return toolResult( ++ result, ++ result.matches.length === 0 ++ ? "No relevant skills found." ++ : `Matched skills: ${result.matches.map((match) => match.name).join(", ")}`, ++ ); ++ }, ++ ); ++ } ++ ++ if (enabledTools.has("project_snapshot")) { ++ registerProjectSnapshot(server, input); ++ registerFocusedContext(server, input); ++ registerReviewChanges(server, input); ++ } ++ ++ if (enabledTools.has("design_audit")) { ++ registerAppTool( ++ server, ++ "design_audit", ++ { ++ title: "Design audit", ++ description: "Run a guarded rendered UI audit through the configured browser adapter.", ++ inputSchema: { ++ workspaceId: z.string(), ++ url: z.string().min(1).max(2_000), ++ desktopViewport: viewportSchema.optional(), ++ mobileViewport: viewportSchema.optional(), ++ routes: z.array(z.string().max(500)).max(20).optional(), ++ checks: z.array(z.string().max(100)).max(20).optional(), ++ outputDirectory: z.string().max(1_000).optional(), ++ }, ++ outputSchema: { ++ status: z.enum(["disabled", "unavailable", "completed"]), ++ adapter: z.string(), ++ validatedUrl: z.string().optional(), ++ artifacts: z.array(z.object({ ++ kind: z.enum(["desktop-screenshot", "mobile-screenshot", "report"]), ++ path: z.string(), ++ })), ++ diagnostics: z.array(z.string()), ++ consoleErrors: z.number().int().nonnegative().optional(), ++ overflowIssues: z.number().int().nonnegative().optional(), ++ accessibilityIssues: z.number().int().nonnegative().optional(), ++ headingIssues: z.number().int().nonnegative().optional(), ++ metrics: metricsSchema, ++ }, ++ _meta: {}, ++ annotations: { ...readOnlyAnnotations, openWorldHint: true }, ++ }, ++ async ({ workspaceId, ...toolInput }) => { ++ const workspace = input.workspaces.getWorkspace(workspaceId); ++ const result = await runDesignAudit(input.config, { ++ workspaceRoot: workspace.root, ++ ...toolInput, ++ }); ++ return { ++ ...toolResult(result, result.diagnostics.join(" ")), ++ isError: result.status !== "completed", ++ }; ++ }, ++ ); ++ } ++} ++ ++export function enabledV11ToolNames(config: ServerConfig): string[] { ++ return [ ++ config.skillMatcher && config.skillsEnabled ? "match_skills" : undefined, ++ ...(config.compoundTools ++ ? ["project_snapshot", "focused_context", "review_changes"] ++ : []), ++ config.designAudit ? "design_audit" : undefined, ++ ].filter((name): name is string => name !== undefined); ++} ++ ++function registerProjectSnapshot( ++ server: McpServer, ++ input: { ++ config: ServerConfig; ++ workspaces: WorkspaceRegistry; ++ localAgentProviders: LocalAgentProviderAvailability[]; ++ }, ++): void { ++ registerAppTool( ++ server, ++ "project_snapshot", ++ { ++ title: "Project snapshot", ++ description: "Return a bounded project and Git digest without diff bodies or secret contents.", ++ inputSchema: { ++ workspaceId: z.string(), ++ maxCharacters: z.number().int().min(2_000).max(50_000).optional(), ++ }, ++ outputSchema: { ++ branch: z.string().nullable(), ++ dirty: z.boolean(), ++ changedFiles: z.array(z.string()), ++ diffStat: z.string(), ++ package: z.object({ ++ name: z.string().optional(), ++ version: z.string().optional(), ++ scripts: z.array(z.string()), ++ }), ++ applicableInstructions: z.array(z.string()), ++ skills: z.array(z.object({ name: z.string(), description: z.string().optional() })), ++ agentProviders: z.array(agentSummarySchema), ++ agentProfiles: z.array(agentSummarySchema), ++ codeGraph: z.object({ ++ detected: z.boolean(), ++ available: z.literal(false), ++ reason: z.enum(["not_initialized", "adapter_unavailable"]), ++ }), ++ recommendedTestCommand: z.string().optional(), ++ recommendedBuildCommand: z.string().optional(), ++ metrics: metricsSchema, ++ }, ++ _meta: {}, ++ annotations: readOnlyAnnotations, ++ }, ++ async ({ workspaceId, maxCharacters }) => { ++ const workspace = input.workspaces.getWorkspace(workspaceId); ++ const result = await projectSnapshot( ++ inspectionContext(workspace, input.localAgentProviders), ++ { maxCharacters }, ++ ); ++ return toolResult( ++ result, ++ `Project snapshot: ${result.changedFiles.length} changed file(s), branch ${result.branch ?? "unknown"}.`, ++ ); ++ }, ++ ); ++} ++ ++function registerFocusedContext( ++ server: McpServer, ++ input: { ++ workspaces: WorkspaceRegistry; ++ localAgentProviders: LocalAgentProviderAvailability[]; ++ }, ++): void { ++ registerAppTool( ++ server, ++ "focused_context", ++ { ++ title: "Focused context", ++ description: "Find bounded files, symbols, and match locations for one focus area.", ++ inputSchema: { ++ workspaceId: z.string(), ++ focus: z.string().min(1).max(4_000), ++ paths: z.array(z.string().max(1_000)).max(25).optional(), ++ maxFiles: z.number().int().min(1).max(25).optional(), ++ maxCharacters: z.number().int().min(2_000).max(50_000).optional(), ++ }, ++ outputSchema: { ++ relevantFiles: z.array(z.string()), ++ relevantSymbols: z.array(z.object({ name: z.string(), path: z.string(), line: z.number().int().positive() })), ++ searchMatches: z.array(z.object({ path: z.string(), line: z.number().int().positive() })), ++ applicableInstructions: z.array(z.string()), ++ impactCandidates: z.array(z.string()), ++ recommendedReads: z.array(z.string()), ++ detectionMethod: z.enum(["bounded_text_fallback", "codegraph_adapter_unavailable_fallback"]), ++ metrics: metricsSchema, ++ }, ++ _meta: {}, ++ annotations: readOnlyAnnotations, ++ }, ++ async ({ workspaceId, ...toolInput }) => { ++ const workspace = input.workspaces.getWorkspace(workspaceId); ++ const result = await focusedContext( ++ inspectionContext(workspace, input.localAgentProviders), ++ toolInput, ++ ); ++ return toolResult(result, `Focused context: ${result.relevantFiles.length} relevant file(s).`); ++ }, ++ ); ++} ++ ++function registerReviewChanges( ++ server: McpServer, ++ input: { ++ workspaces: WorkspaceRegistry; ++ localAgentProviders: LocalAgentProviderAvailability[]; ++ }, ++): void { ++ registerAppTool( ++ server, ++ "review_changes", ++ { ++ title: "Review changes", ++ description: "Analyze a bounded Git diff without changing files, index, refs, or staging.", ++ inputSchema: { ++ workspaceId: z.string(), ++ scope: z.string().max(1_000).optional(), ++ baseRef: z.string().max(200).optional(), ++ maxCharacters: z.number().int().min(2_000).max(50_000).optional(), ++ }, ++ outputSchema: { ++ changedFiles: z.array(z.object({ path: z.string(), status: z.string() })), ++ diffStat: z.string(), ++ summary: z.string(), ++ riskCandidates: z.array(z.string()), ++ suspiciousChanges: z.array(z.object({ rule: z.string(), file: z.string(), line: z.number().int().positive().optional() })), ++ testRecommendations: z.array(z.string()), ++ truncated: z.boolean(), ++ metrics: metricsSchema, ++ }, ++ _meta: {}, ++ annotations: readOnlyAnnotations, ++ }, ++ async ({ workspaceId, ...toolInput }) => { ++ const workspace = input.workspaces.getWorkspace(workspaceId); ++ const result = await reviewChanges( ++ inspectionContext(workspace, input.localAgentProviders), ++ toolInput, ++ ); ++ return toolResult(result, result.summary); ++ }, ++ ); ++} ++ ++const viewportSchema = z.object({ ++ width: z.number().int().min(320).max(8_000), ++ height: z.number().int().min(320).max(8_000), ++}); ++ ++const agentSummarySchema = z.object({ ++ name: z.string(), ++ provider: z.string(), ++ available: z.boolean().optional(), ++ writeMode: z.string().optional(), ++}); ++ ++function inspectionContext( ++ workspace: Workspace, ++ providers: LocalAgentProviderAvailability[], ++): WorkspaceInspectionContext { ++ return { ++ root: workspace.root, ++ instructionPaths: Array.from(workspace.advertisedInstructionPaths).sort(), ++ skills: workspace.skills ++ .filter((skill) => !skill.disableModelInvocation) ++ .map((skill) => ({ name: skill.name, description: skill.description })), ++ agentProviders: providers.map((provider) => ({ ++ name: provider.name, ++ provider: provider.name, ++ available: provider.available, ++ })), ++ agentProfiles: workspace.agentProfiles.map((profile) => ({ ++ name: profile.name, ++ provider: profile.provider, ++ writeMode: profile.writeMode, ++ })), ++ }; ++} ++ ++function toolResult>(structuredContent: T, summary: string) { ++ return { ++ content: [{ type: "text" as const, text: summary.slice(0, 1_000) }], ++ structuredContent, ++ }; ++} +diff --git a/src/register-v11-tools.test.ts b/src/register-v11-tools.test.ts +new file mode 100644 +index 0000000..ce589c7 +--- /dev/null ++++ b/src/register-v11-tools.test.ts +@@ -0,0 +1,26 @@ ++import assert from "node:assert/strict"; ++import { loadConfig } from "./config.js"; ++import { enabledV11ToolNames } from "./register-v11-tools.js"; ++ ++const baseEnv = { ++ DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", ++}; ++ ++assert.deepEqual(enabledV11ToolNames(loadConfig(baseEnv)), []); ++assert.deepEqual( ++ enabledV11ToolNames(loadConfig({ ++ ...baseEnv, ++ DEVSPACE_SKILL_MATCHER: "1", ++ DEVSPACE_COMPOUND_TOOLS: "1", ++ DEVSPACE_DESIGN_AUDIT: "1", ++ })), ++ ["match_skills", "project_snapshot", "focused_context", "review_changes", "design_audit"], ++); ++assert.deepEqual( ++ enabledV11ToolNames(loadConfig({ ++ ...baseEnv, ++ DEVSPACE_SKILL_MATCHER: "1", ++ DEVSPACE_SKILLS: "0", ++ })), ++ [], ++); +diff --git a/src/tool-metrics.ts b/src/tool-metrics.ts +new file mode 100644 +index 0000000..8e2970c +--- /dev/null ++++ b/src/tool-metrics.ts +@@ -0,0 +1,92 @@ ++export interface ToolMetrics { ++ serverDurationMs: number; ++ payloadCharacters: number; ++ returnedItems: number; ++ truncated: boolean; ++ cacheHit?: boolean; ++} ++ ++export interface BoundedBuild { ++ payload: T; ++ returnedItems: number; ++ truncated: boolean; ++} ++ ++export function measuredPayload( ++ payload: T, ++ input: { ++ startedAt: number; ++ returnedItems: number; ++ truncated: boolean; ++ cacheHit?: boolean; ++ }, ++): T & { metrics: ToolMetrics } { ++ const metrics: ToolMetrics = { ++ serverDurationMs: Math.max(0, Math.round(performance.now() - input.startedAt)), ++ payloadCharacters: 0, ++ returnedItems: input.returnedItems, ++ truncated: input.truncated, ++ ...(input.cacheHit === undefined ? {} : { cacheHit: input.cacheHit }), ++ }; ++ const result = { ...payload, metrics }; ++ ++ for (let attempt = 0; attempt < 4; attempt += 1) { ++ const characters = JSON.stringify(result).length; ++ if (metrics.payloadCharacters === characters) break; ++ metrics.payloadCharacters = characters; ++ } ++ ++ return result; ++} ++ ++export function buildBoundedPayload(input: { ++ startedAt: number; ++ maxCharacters: number; ++ cacheHit?: boolean; ++ build(contentBudget: number): BoundedBuild; ++}): T & { metrics: ToolMetrics } { ++ let contentBudget = Math.max(0, input.maxCharacters - 512); ++ let result: T & { metrics: ToolMetrics }; ++ ++ for (let attempt = 0; attempt < 5; attempt += 1) { ++ const built = input.build(contentBudget); ++ result = measuredPayload(built.payload, { ++ startedAt: input.startedAt, ++ returnedItems: built.returnedItems, ++ truncated: built.truncated, ++ cacheHit: input.cacheHit, ++ }); ++ if (result.metrics.payloadCharacters <= input.maxCharacters) return result; ++ contentBudget = Math.max( ++ 0, ++ contentBudget - (result.metrics.payloadCharacters - input.maxCharacters) - 64, ++ ); ++ } ++ ++ throw new Error(`Unable to fit tool payload within ${input.maxCharacters} characters.`); ++} ++ ++export function takeWithinCharacterBudget( ++ items: T[], ++ budget: number, ++): { items: T[]; truncated: boolean } { ++ const selected: T[] = []; ++ let used = 2; ++ for (const item of items) { ++ const characters = JSON.stringify(item).length + (selected.length > 0 ? 1 : 0); ++ if (used + characters > budget) return { items: selected, truncated: true }; ++ selected.push(item); ++ used += characters; ++ } ++ return { items: selected, truncated: false }; ++} ++ ++export function clampInteger( ++ value: number | undefined, ++ fallback: number, ++ minimum: number, ++ maximum: number, ++): number { ++ if (value === undefined) return fallback; ++ return Math.min(maximum, Math.max(minimum, Math.floor(value))); ++} diff --git a/compatibility-kit/openai-model-compatibility-2026-07/patches/0003-agents-skills-app-integration.patch b/compatibility-kit/openai-model-compatibility-2026-07/patches/0003-agents-skills-app-integration.patch new file mode 100644 index 00000000..ea274686 --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/patches/0003-agents-skills-app-integration.patch @@ -0,0 +1,1574 @@ +diff --git a/docs/configuration.md b/docs/configuration.md +index 4c02eb1..cd4bfa6 100644 +--- a/docs/configuration.md ++++ b/docs/configuration.md +@@ -83,9 +83,66 @@ sessions. + + | Value | Behavior | + | --- | --- | +-| `full` | Default. Widget UI is attached to exposed workspace, file, edit, and shell tools. | ++| `full` | Widget UI is attached to exposed workspace, file, edit, and shell tools. This is the default when full workspace payloads are selected. | + | `changes` | Enables the aggregate `show_changes` tool and attaches widget UI to `open_workspace` and `show_changes`. | +-| `off` | Disables widget UI. | ++| `off` | Disables widget UI. This is the default in compact mode. | ++ ++## Compact payloads and usage estimates ++ ++| Variable | Default | Purpose | ++| --- | --- | --- | ++| `DEVSPACE_OPEN_WORKSPACE_PAYLOAD` | `compact` | Use `compact` for bounded instruction excerpts and smaller skill metadata, or `full` for the v1.0 payload. | ++| `DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS` | `6000` | Maximum characters returned per instruction excerpt; minimum `256`. | ++| `DEVSPACE_USAGE_CONTENT` | `compact` | Append `compact` or `full` observed-token estimates to tool output, or use `off` to hide them. | ++| `DEVSPACE_USAGE_HISTORY` | `~/.local/share/devspace/usage-history.jsonl` | Local JSONL destination for non-blocking usage diagnostics. | ++ ++Token counts are estimates from text handled by DevSpace, not ChatGPT model billing or actual model usage. ++ ++## DevSpace v1.1 feature flags ++ ++All v1.1 feature flags default to `0`, so the existing compact Tool catalog and Fast Path remain unchanged. ++ ++| Variable | Enables | ++| --- | --- | ++| `DEVSPACE_SKILL_MATCHER` | `match_skills`, which ranks bounded Skill metadata without loading bodies. | ++| `DEVSPACE_COMPOUND_TOOLS` | `project_snapshot`, `focused_context`, and read-only `review_changes`. | ++| `DEVSPACE_BUILTIN_PROFILES` | Built-in `explore`, `implement`, `review`, and `design` profiles when Subagents are enabled. | ++| `DEVSPACE_DESIGN_AUDIT` | The guarded `design_audit` adapter and three bundled Design Skills. | ++| `DEVSPACE_DESIGN_AUDIT_ALLOWED_HOSTS` | Comma-separated exact hosts/origins; defaults to loopback hosts only. | ++ ++Feature flag values are strict: `1/0`, `true/false`, `yes/no`, and `on/off` are accepted. ++New Tool results include `serverDurationMs`, `payloadCharacters`, `returnedItems`, and `truncated`. ++ ++The v1.1 package intentionally ships Design Audit as an adapter only: no Playwright/CDP/axe ++runtime is currently bundled, no browser binary is downloaded, and an enabled Tool returns an ++unavailable error until a real adapter is connected. A future adapter must use an ephemeral ++browser, validate redirects and subresources against the same URL policy, avoid cookies, write ++artifacts only inside the requested workspace directory, and terminate the browser in `finally`. ++ ++Skills may optionally declare bounded `short-description`, `triggers`, and `required-tools` ++frontmatter for matching. Existing `name` and `description` metadata remains fully compatible; ++only a selected Skill is activated by reading its `SKILL.md`. ++ ++## Approved shell aliases ++ ++`DEVSPACE_APPROVED_SHELL_COMMANDS_FILE` may point to a local JSON file. The ++default is `~/.devspace/approved-shell-commands.json`. An alias is invoked as ++`devspace-approved ` and is accepted only when its configured ++`workspaceRoot` exactly matches the open workspace. Its optional ++`workingDirectory` must remain inside that root. ++ ++```json ++{ ++ "commands": [ ++ { ++ "alias": "verify", ++ "workspaceRoot": "/absolute/project/path", ++ "workingDirectory": ".", ++ "command": "npm test" ++ } ++ ] ++} ++``` + + ## Skills + +diff --git a/src/cli.ts b/src/cli.ts +index bbae1ae..226aec1 100644 +--- a/src/cli.ts ++++ b/src/cli.ts +@@ -13,6 +13,7 @@ import { satisfies } from "semver"; + import { loadConfig } from "./config.js"; + import { runLocalAgentProvider } from "./local-agent-adapters.js"; + import { ++ assertLocalAgentProfileBoundary, + isLocalAgentProvider, + loadLocalAgentProfiles, + type LocalAgentProfile, +@@ -392,6 +393,7 @@ async function runAgentsRun(args: string[]): Promise { + `Unknown subagent profile, provider, or id: ${parsed.target}. Available ${formatAvailableLocalAgentTargets(profiles)}`, + ); + } ++ if (target.kind === "profile") assertLocalAgentProfileBoundary(target.profile); + assertLocalAgentProviderAvailable(target.provider); + + const promptFile = writeAgentPromptFile(parsed.prompt); +@@ -475,13 +477,14 @@ async function runLocalAgentProfile( + record: LocalAgentRecord, + prompt: string, + ): Promise { ++ assertLocalAgentProfileBoundary(profile); + const body = profile.body.trim(); + const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; + return runLocalAgentProvider(profile.provider, { + prompt: fullPrompt, + workspace: record.workspaceRoot, + providerSessionId: record.providerSessionId, +- writeMode: "allowed", ++ writeMode: profile.writeMode, + model: record.model ?? profile.model, + thinking: record.thinking ?? profile.thinking, + }); +diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts +index 7665b9f..1e77ddf 100644 +--- a/src/local-agent-profiles.test.ts ++++ b/src/local-agent-profiles.test.ts +@@ -3,7 +3,11 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; + import { tmpdir } from "node:os"; + import { join } from "node:path"; + import { loadConfig } from "./config.js"; +-import { loadLocalAgentProfiles, summarizeLocalAgentProfile } from "./local-agent-profiles.js"; ++import { ++ assertLocalAgentProfileBoundary, ++ loadLocalAgentProfiles, ++ summarizeLocalAgentProfile, ++} from "./local-agent-profiles.js"; + + const root = await mkdtemp(join(tmpdir(), "devspace-agent-profiles-test-")); + +@@ -71,6 +75,7 @@ try { + assert.equal(profiles[0]?.provider, "claude"); + assert.equal(profiles[0]?.model, "sonnet"); + assert.equal(profiles[0]?.thinking, "high"); ++ assert.equal(profiles[0]?.writeMode, "allowed"); + assert.equal(profiles[0]?.body, "Project body."); + assert.deepEqual(summarizeLocalAgentProfile(profiles[0]!), { + name: "reviewer", +@@ -78,8 +83,54 @@ try { + provider: "claude", + model: "sonnet", + thinking: "high", ++ writeMode: "allowed", + }); + ++ await writeFile( ++ join(workspaceRoot, ".devspace", "agents", "readonly.md"), ++ [ ++ "---", ++ "name: readonly", ++ "description: Read-only reviewer.", ++ "provider: codex", ++ "write-mode: read_only", ++ "---", ++ "", ++ "Review only.", ++ ].join("\n"), ++ ); ++ const withReadOnly = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); ++ assert.equal(withReadOnly.find((profile) => profile.name === "readonly")?.writeMode, "read_only"); ++ assert.doesNotThrow(() => assertLocalAgentProfileBoundary( ++ withReadOnly.find((profile) => profile.name === "readonly")!, ++ )); ++ assert.throws( ++ () => assertLocalAgentProfileBoundary({ ++ ...withReadOnly.find((profile) => profile.name === "readonly")!, ++ provider: "claude", ++ }), ++ /requires the codex provider/, ++ ); ++ assert.throws( ++ () => assertLocalAgentProfileBoundary({ ++ ...withReadOnly.find((profile) => profile.name === "readonly")!, ++ name: "codex", ++ }), ++ /reserved for the raw provider target/, ++ ); ++ ++ await writeFile( ++ join(workspaceRoot, ".devspace", "agents", "invalid-mode.md"), ++ [ ++ "---", ++ "name: invalid-mode", ++ "description: Invalid mode.", ++ "provider: codex", ++ "write-mode: full_access", ++ "---", ++ ].join("\n"), ++ ); ++ + await writeFile( + join(workspaceRoot, ".devspace", "agents", "custom.md"), + [ +@@ -94,7 +145,21 @@ try { + ].join("\n"), + ); + const profilesWithInvalid = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); +- assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["reviewer"]); ++ assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["readonly", "reviewer"]); ++ ++ const builtinConfig = loadConfig({ ++ DEVSPACE_CONFIG_DIR: configDir, ++ DEVSPACE_ALLOWED_ROOTS: workspaceRoot, ++ DEVSPACE_SUBAGENTS: "1", ++ DEVSPACE_BUILTIN_PROFILES: "1", ++ DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", ++ }); ++ const builtinProfiles = await loadLocalAgentProfiles(builtinConfig, workspaceRoot); ++ for (const name of ["design", "explore", "implement", "review"]) { ++ assert.ok(builtinProfiles.some((profile) => profile.name === name)); ++ } ++ assert.equal(builtinProfiles.find((profile) => profile.name === "explore")?.writeMode, "read_only"); ++ assert.equal(builtinProfiles.find((profile) => profile.name === "implement")?.writeMode, "allowed"); + + const disabledConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: configDir, +diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts +index a7fa0d8..2d37d08 100644 +--- a/src/local-agent-profiles.ts ++++ b/src/local-agent-profiles.ts +@@ -1,8 +1,10 @@ + import { existsSync } from "node:fs"; + import { readdir, readFile } from "node:fs/promises"; + import { basename, join, resolve } from "node:path"; ++import { fileURLToPath } from "node:url"; + import { parse as parseYaml } from "yaml"; + import type { ServerConfig } from "./config.js"; ++import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; + + export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot"; + +@@ -21,6 +23,7 @@ export interface LocalAgentProfile { + provider: LocalAgentProvider; + model?: string; + thinking?: string; ++ writeMode: Exclude; + filePath: string; + body: string; + disabled: boolean; +@@ -32,6 +35,7 @@ export interface LocalAgentProfileSummary { + provider: LocalAgentProvider; + model?: string; + thinking?: string; ++ writeMode: Exclude; + } + + interface ParsedFrontmatter { +@@ -49,9 +53,10 @@ export async function loadLocalAgentProfiles( + if (!config.subagents) return []; + + const profileDirs = [ ++ config.builtinProfiles ? builtinProfilesDir() : undefined, + config.devspaceAgentsDir, + join(workspaceRoot, ".devspace", "agents"), +- ]; ++ ].filter((directory): directory is string => directory !== undefined); + const profilesByName = new Map(); + + for (const directory of profileDirs) { +@@ -74,6 +79,7 @@ export function summarizeLocalAgentProfile( + provider: profile.provider, + model: profile.model, + thinking: profile.thinking, ++ writeMode: profile.writeMode, + }; + } + +@@ -158,12 +164,27 @@ function profileFromFrontmatter( + provider, + model: readString(frontmatter, "model"), + thinking: readString(frontmatter, "thinking"), ++ writeMode: readWriteMode(frontmatter, filePath), + filePath, + body, + disabled: frontmatter.disabled === true, + }; + } + ++function builtinProfilesDir(): string { ++ return fileURLToPath(new URL("../agents", import.meta.url)); ++} ++ ++function readWriteMode( ++ frontmatter: Record, ++ filePath: string, ++): Exclude { ++ const value = readString(frontmatter, "write-mode") ?? readString(frontmatter, "writeMode"); ++ if (!value || value === "allowed") return "allowed"; ++ if (value === "read_only") return value; ++ throw new Error(`Subagent profile write-mode must be read_only or allowed: ${filePath}`); ++} ++ + function readProvider(frontmatter: Record, filePath: string): LocalAgentProvider { + const provider = readString(frontmatter, "provider"); + if (!provider) { +@@ -181,6 +202,19 @@ export function isLocalAgentProvider(value: string): value is LocalAgentProvider + return PROVIDERS.has(value as LocalAgentProvider); + } + ++export function assertLocalAgentProfileBoundary(profile: LocalAgentProfile): void { ++ if (profile.writeMode === "read_only" && profile.provider !== "codex") { ++ throw new Error( ++ `Read-only profile ${profile.name} requires the codex provider; ${profile.provider} cannot enforce this boundary.`, ++ ); ++ } ++ if (profile.writeMode === "read_only" && profile.name === profile.provider) { ++ throw new Error( ++ `Read-only profile name ${profile.name} is reserved for the raw provider target.`, ++ ); ++ } ++} ++ + function readString(frontmatter: Record, key: string): string | undefined { + const value = frontmatter[key]; + if (typeof value !== "string") return undefined; +diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts +index 1d45d16..5483f59 100644 +--- a/src/local-agent-runtime.test.ts ++++ b/src/local-agent-runtime.test.ts +@@ -56,6 +56,8 @@ assert.deepEqual(codex.started[0], { + approvalPolicy: "never", + model: undefined, + modelReasoningEffort: undefined, ++ networkAccessEnabled: false, ++ webSearchMode: "disabled", + }); + + await runtime.run({ +@@ -72,6 +74,8 @@ assert.deepEqual(codex.started[1], { + approvalPolicy: "never", + model: "gpt-5.4", + modelReasoningEffort: "high", ++ networkAccessEnabled: undefined, ++ webSearchMode: undefined, + }); + + const resumed = await runtime.run({ +@@ -92,6 +96,8 @@ assert.deepEqual(codex.resumed, [ + approvalPolicy: "never", + model: undefined, + modelReasoningEffort: undefined, ++ networkAccessEnabled: undefined, ++ webSearchMode: undefined, + }, + }, + ]); +diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts +index 54130c2..a9928f8 100644 +--- a/src/local-agent-runtime.ts ++++ b/src/local-agent-runtime.ts +@@ -55,12 +55,15 @@ function sandboxModeFor(writeMode: LocalAgentWriteMode | undefined): SandboxMode + } + + function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { ++ const readOnly = input.writeMode === "read_only" || input.writeMode === undefined; + return { + workingDirectory: input.workspace, + sandboxMode: sandboxModeFor(input.writeMode), + approvalPolicy: "never", + model: input.model, + modelReasoningEffort: input.thinking as ModelReasoningEffort | undefined, ++ networkAccessEnabled: readOnly ? false : undefined, ++ webSearchMode: readOnly ? "disabled" : undefined, + }; + } + +diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts +index 3f1ae08..dcb45c0 100644 +--- a/src/local-agent-targets.test.ts ++++ b/src/local-agent-targets.test.ts +@@ -13,6 +13,7 @@ const profiles: LocalAgentProfile[] = [ + provider: "codex", + model: "gpt-5-codex", + thinking: "high", ++ writeMode: "read_only", + filePath: "/workspace/.devspace/agents/reviewer.md", + body: "Review carefully.", + disabled: false, +@@ -22,6 +23,7 @@ const profiles: LocalAgentProfile[] = [ + description: "A profile that shadows the raw provider.", + provider: "opencode", + model: "qwen/custom", ++ writeMode: "allowed", + filePath: "/workspace/.devspace/agents/claude.md", + body: "Use OpenCode.", + disabled: false, +diff --git a/src/skills.test.ts b/src/skills.test.ts +index 707dda2..e2b69fa 100644 +--- a/src/skills.test.ts ++++ b/src/skills.test.ts +@@ -209,6 +209,22 @@ try { + true, + ); + ++ const designConfig = loadConfig({ ++ DEVSPACE_ALLOWED_ROOTS: projectRoot, ++ DEVSPACE_AGENT_DIR: agentDir, ++ DEVSPACE_DESIGN_AUDIT: "1", ++ DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", ++ PORT: "1", ++ }); ++ const designSkills = loadWorkspaceSkills(designConfig, projectRoot).skills; ++ for (const name of [ ++ "design-system-audit", ++ "responsive-accessibility-audit", ++ "product-ui-review", ++ ]) { ++ assert.ok(designSkills.some((skill) => skill.name === name)); ++ } ++ + const duplicateConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_AGENT_DIR: agentDir, +diff --git a/src/skills.ts b/src/skills.ts +index c1f146a..a63bea1 100644 +--- a/src/skills.ts ++++ b/src/skills.ts +@@ -39,7 +39,7 @@ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] + resolve(cwd, ".agents", "skills"), + config.devspaceSkillsDir, + join(config.agentDir, "skills"), +- config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir) ++ (config.designAudit || (config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir))) + ? bundledSkills + : undefined, + ]; +diff --git a/src/skill-matcher.ts b/src/skill-matcher.ts +new file mode 100644 +index 0000000..f51d9b2 +--- /dev/null ++++ b/src/skill-matcher.ts +@@ -0,0 +1,276 @@ ++import { open, realpath, type FileHandle } from "node:fs/promises"; ++import { parse as parseYaml } from "yaml"; ++import type { Skill } from "@earendil-works/pi-coding-agent"; ++import { formatPathForPrompt } from "./skills.js"; ++import { isPathInsideRoot } from "./roots.js"; ++import { containsSecretValue, isSecretLikePath, safeRealFile } from "./safe-inspection.js"; ++import { ++ buildBoundedPayload, ++ clampInteger, ++ takeWithinCharacterBudget, ++ type ToolMetrics, ++} from "./tool-metrics.js"; ++ ++export interface SkillMatch { ++ name: string; ++ shortDescription: string; ++ path: string; ++ matchReason: string; ++ confidence: number; ++ requiredTools?: string[]; ++} ++ ++export interface SkillMatchResult extends Record { ++ matches: SkillMatch[]; ++ metrics: ToolMetrics; ++} ++ ++interface IndexedSkill { ++ match: Omit; ++ normalizedName: string; ++ nameTokens: Set; ++ descriptionTokens: Set; ++ triggers: string[]; ++ triggerTokens: Set; ++ global: boolean; ++} ++ ++const FRONTMATTER_BYTES = 16 * 1024; ++const DEFAULT_LIMIT = 3; ++const MAX_LIMIT = 10; ++const MATCH_PAYLOAD_CHARACTERS = 8_000; ++const metadataCache = new WeakMap>(); ++const STOP_WORDS = new Set([ ++ "and", "for", "from", "into", "the", "this", "that", "use", "with", ++ "your", "task", "work", "using", "when", "where", "what", "how", ++]); ++ ++export async function matchWorkspaceSkills(input: { ++ skills: Skill[]; ++ workspaceRoot: string; ++ task: string; ++ limit?: number; ++ includeGlobal?: boolean; ++}): Promise { ++ const startedAt = performance.now(); ++ const cacheHit = metadataCache.has(input.skills); ++ let indexPromise = metadataCache.get(input.skills); ++ if (!indexPromise) { ++ indexPromise = buildSkillIndex(input.skills, input.workspaceRoot); ++ metadataCache.set(input.skills, indexPromise); ++ } ++ ++ const index = await indexPromise; ++ const limit = clampInteger(input.limit, DEFAULT_LIMIT, 1, MAX_LIMIT); ++ const task = normalize(input.task).slice(0, 2_000); ++ const taskTokens = tokens(task); ++ const candidates = index ++ .filter((skill) => input.includeGlobal === true || !skill.global) ++ .map((skill) => scoreSkill(skill, task, taskTokens)) ++ .filter((candidate): candidate is SkillMatch => candidate !== undefined) ++ .sort((a, b) => b.confidence - a.confidence || a.name.localeCompare(b.name)); ++ const limited = candidates.slice(0, limit); ++ ++ return buildBoundedPayload({ ++ startedAt, ++ maxCharacters: MATCH_PAYLOAD_CHARACTERS, ++ cacheHit, ++ build: (contentBudget) => { ++ const bounded = takeWithinCharacterBudget(limited, contentBudget); ++ return { ++ payload: { matches: bounded.items }, ++ returnedItems: bounded.items.length, ++ truncated: candidates.length > limited.length || bounded.truncated, ++ }; ++ }, ++ }); ++} ++ ++async function buildSkillIndex(skills: Skill[], workspaceRoot: string): Promise { ++ let realWorkspaceRoot: string; ++ try { ++ realWorkspaceRoot = await realpath(workspaceRoot); ++ } catch { ++ return []; ++ } ++ ++ const indexed = await Promise.all(skills.map(async (skill): Promise => { ++ if (skill.disableModelInvocation || isSecretLikePath(skill.filePath)) return undefined; ++ const realFile = await safeRealFile(skill.filePath, skill.baseDir); ++ if (!realFile) return undefined; ++ ++ const frontmatter = await readBoundedFrontmatter(realFile); ++ const shortDescription = readShortDescription(frontmatter) ?? skill.description.trim(); ++ const triggers = readStringList( ++ frontmatter, ++ ["triggers", "__body-triggers"], ++ 12, ++ 120, ++ ); ++ const requiredTools = readStringList( ++ frontmatter, ++ ["required-tools", "requiredTools", "__body-required-tools"], ++ 12, ++ 64, ++ ).filter((tool) => /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(tool)); ++ ++ if (!shortDescription || containsSecretValue(shortDescription) || triggers.some(containsSecretValue)) { ++ return undefined; ++ } ++ ++ const normalizedName = normalize(skill.name.replaceAll("-", " ")); ++ return { ++ match: { ++ name: skill.name.slice(0, 100), ++ shortDescription: shortDescription.slice(0, 240), ++ path: formatPathForPrompt(skill.filePath), ++ ...(requiredTools.length > 0 ? { requiredTools } : {}), ++ }, ++ normalizedName, ++ nameTokens: tokens(normalizedName), ++ descriptionTokens: tokens(shortDescription), ++ triggers: triggers.map(normalize), ++ triggerTokens: tokens(triggers.join(" ")), ++ global: !isPathInsideRoot(realFile, realWorkspaceRoot), ++ }; ++ })); ++ ++ return indexed.filter((skill): skill is IndexedSkill => skill !== undefined); ++} ++ ++function scoreSkill( ++ skill: IndexedSkill, ++ task: string, ++ taskTokens: Set, ++): SkillMatch | undefined { ++ const matchedFields: string[] = []; ++ let score = 0; ++ ++ const triggerPhrase = skill.triggers.find((trigger) => ++ trigger.length >= 3 && (task.includes(trigger) || trigger.includes(task)) ++ ); ++ if (triggerPhrase) { ++ score += 0.62; ++ matchedFields.push("trigger"); ++ } ++ ++ if (skill.normalizedName.length >= 3 && task.includes(skill.normalizedName)) { ++ score += 0.55; ++ matchedFields.push("name"); ++ } ++ ++ const nameOverlap = overlap(taskTokens, skill.nameTokens); ++ if (nameOverlap > 0) { ++ score += Math.min(0.38, nameOverlap * 0.19); ++ if (!matchedFields.includes("name")) matchedFields.push("name"); ++ } ++ ++ const triggerOverlap = overlap(taskTokens, skill.triggerTokens); ++ if (triggerOverlap > 0) { ++ score += Math.min(0.34, triggerOverlap * 0.17); ++ if (!matchedFields.includes("trigger")) matchedFields.push("trigger"); ++ } ++ ++ const descriptionOverlap = overlap(taskTokens, skill.descriptionTokens); ++ if (descriptionOverlap > 0) { ++ score += Math.min(0.3, descriptionOverlap * 0.1); ++ matchedFields.push("description"); ++ } ++ ++ if (score < 0.2) return undefined; ++ const confidence = Math.min(0.99, Math.round(score * 100) / 100); ++ return { ++ ...skill.match, ++ matchReason: `Matched ${Array.from(new Set(matchedFields)).join(" + ")}.`, ++ confidence, ++ }; ++} ++ ++function overlap(left: Set, right: Set): number { ++ let count = 0; ++ for (const token of left) { ++ if (right.has(token)) count += 1; ++ } ++ return count; ++} ++ ++function normalize(value: string): string { ++ return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim(); ++} ++ ++function tokens(value: string): Set { ++ const output = new Set(); ++ const normalized = normalize(value); ++ const segmenter = new Intl.Segmenter(undefined, { granularity: "word" }); ++ for (const segment of segmenter.segment(normalized)) { ++ if (!segment.isWordLike) continue; ++ const token = segment.segment.trim(); ++ if (token.length < 2 || STOP_WORDS.has(token)) continue; ++ output.add(token); ++ } ++ return output; ++} ++ ++async function readBoundedFrontmatter(path: string): Promise> { ++ let handle: FileHandle | undefined; ++ try { ++ handle = await open(path, "r"); ++ const buffer = Buffer.alloc(FRONTMATTER_BYTES); ++ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); ++ const source = buffer.subarray(0, bytesRead).toString("utf8").replace(/^\uFEFF/, ""); ++ const lines = source.split(/\r?\n/); ++ if (lines[0]?.trim() !== "---") return {}; ++ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---"); ++ if (end === -1) return {}; ++ const parsed = parseYaml(lines.slice(1, end).join("\n")); ++ const frontmatter = parsed && typeof parsed === "object" && !Array.isArray(parsed) ++ ? parsed as Record ++ : {}; ++ const bodyLines = lines.slice(end + 1); ++ return { ++ ...frontmatter, ++ "__body-triggers": readMarkdownList(bodyLines, "Triggers"), ++ "__body-required-tools": readMarkdownList(bodyLines, "Required tools") ++ .map((value) => value.replace(/^`|`$/g, "")), ++ }; ++ } catch { ++ return {}; ++ } finally { ++ await handle?.close(); ++ } ++} ++ ++function readMarkdownList(lines: string[], heading: string): string[] { ++ const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading.toLowerCase()}`); ++ if (start === -1) return []; ++ const values: string[] = []; ++ for (const line of lines.slice(start + 1)) { ++ if (/^##\s+/.test(line.trim())) break; ++ const match = line.match(/^\s*[-*]\s+(.+?)\s*$/); ++ if (match?.[1]) values.push(match[1]); ++ } ++ return values; ++} ++ ++function readShortDescription(frontmatter: Record): string | undefined { ++ for (const key of ["short-description", "shortDescription"]) { ++ const value = frontmatter[key]; ++ if (typeof value === "string" && value.trim()) return value.trim(); ++ } ++ return undefined; ++} ++ ++function readStringList( ++ frontmatter: Record, ++ keys: string[], ++ maxItems: number, ++ maxCharacters: number, ++): string[] { ++ const value = keys.map((key) => frontmatter[key]).find((candidate) => candidate !== undefined); ++ const values = typeof value === "string" ? [value] : Array.isArray(value) ? value : []; ++ return values ++ .filter((entry): entry is string => typeof entry === "string") ++ .map((entry) => entry.trim().slice(0, maxCharacters)) ++ .filter(Boolean) ++ .slice(0, maxItems); ++} +diff --git a/src/skill-matcher.test.ts b/src/skill-matcher.test.ts +new file mode 100644 +index 0000000..ca40083 +--- /dev/null ++++ b/src/skill-matcher.test.ts +@@ -0,0 +1,118 @@ ++import assert from "node:assert/strict"; ++import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import type { Skill } from "@earendil-works/pi-coding-agent"; ++import { matchWorkspaceSkills } from "./skill-matcher.js"; ++import { resolveSkillReadPath } from "./skills.js"; ++ ++const root = await mkdtemp(join(tmpdir(), "gpt-agent-skill-matcher-test-")); ++const workspaceRoot = join(root, "project"); ++const globalRoot = join(root, "global"); ++ ++try { ++ await mkdir(workspaceRoot, { recursive: true }); ++ await mkdir(globalRoot, { recursive: true }); ++ const projectSkill = await skill(workspaceRoot, "dashboard-audit", [ ++ "---", ++ "name: dashboard-audit", ++ "description: Audit dashboard accessibility and responsive layout.", ++ "short-description: Check dashboard UI accessibility.", ++ "triggers:", ++ " - dashboard audit", ++ " - accessibility review", ++ "required-tools:", ++ " - design_audit", ++ " - invalid tool value", ++ "---", ++ "Body must not be returned.", ++ ].join("\n")); ++ const otherSkill = await skill(workspaceRoot, "database-migration", [ ++ "---", ++ "name: database-migration", ++ "description: Migrate relational database schemas.", ++ "---", ++ "Database body.", ++ ].join("\n")); ++ const globalSkill = await skill(globalRoot, "global-dashboard", [ ++ "---", ++ "name: global-dashboard", ++ "description: Review global dashboard navigation.", ++ "---", ++ "Global body.", ++ ].join("\n")); ++ const disabledSkill = { ...otherSkill, name: "disabled-dashboard", disableModelInvocation: true }; ++ const skills = [projectSkill, otherSkill, globalSkill, disabledSkill]; ++ ++ const first = await matchWorkspaceSkills({ ++ skills, ++ workspaceRoot, ++ task: "Run a dashboard audit and accessibility review", ++ limit: 1, ++ }); ++ assert.deepEqual(first.matches.map((match) => match.name), ["dashboard-audit"]); ++ assert.deepEqual(first.matches[0]?.requiredTools, ["design_audit"]); ++ assert.equal(first.metrics.cacheHit, false); ++ assert.equal(first.metrics.returnedItems, 1); ++ assert.equal(first.metrics.payloadCharacters, JSON.stringify(first).length); ++ assert.equal(JSON.stringify(first).includes("Body must not be returned"), false); ++ assert.equal( ++ resolveSkillReadPath(skills, new Set(), first.matches[0]!.path)?.absolutePath, ++ projectSkill.filePath, ++ ); ++ ++ const second = await matchWorkspaceSkills({ ++ skills, ++ workspaceRoot, ++ task: "Review the dashboard navigation", ++ includeGlobal: true, ++ limit: 1, ++ }); ++ assert.equal(second.metrics.cacheHit, true); ++ assert.equal(second.metrics.truncated, true); ++ assert.equal(second.matches.length, 1); ++ ++ const none = await matchWorkspaceSkills({ ++ skills, ++ workspaceRoot, ++ task: "compile a Rust kernel module", ++ }); ++ assert.deepEqual(none.matches, []); ++ assert.equal(none.metrics.returnedItems, 0); ++ ++ const designBase = join(process.cwd(), "skills", "design-system-audit"); ++ const designSkill = { ++ name: "design-system-audit", ++ description: "Audit rendered interface design systems.", ++ baseDir: designBase, ++ filePath: join(designBase, "SKILL.md"), ++ disableModelInvocation: false, ++ } as Skill; ++ const designMatch = await matchWorkspaceSkills({ ++ skills: [designSkill], ++ workspaceRoot, ++ task: "Run a design system audit", ++ includeGlobal: true, ++ }); ++ assert.deepEqual(designMatch.matches[0]?.requiredTools, [ ++ "design_audit", ++ "focused_context", ++ "read", ++ ]); ++} finally { ++ await rm(root, { recursive: true, force: true }); ++} ++ ++async function skill(base: string, name: string, content: string): Promise { ++ const baseDir = join(base, ".agents", "skills", name); ++ const filePath = join(baseDir, "SKILL.md"); ++ await mkdir(baseDir, { recursive: true }); ++ await writeFile(filePath, content); ++ return { ++ name, ++ description: content.match(/description:\s*([^\n]+)/)?.[1] ?? name, ++ filePath, ++ baseDir, ++ disableModelInvocation: false, ++ } as Skill; ++} +diff --git a/src/design-audit.ts b/src/design-audit.ts +new file mode 100644 +index 0000000..df798dc +--- /dev/null ++++ b/src/design-audit.ts +@@ -0,0 +1,338 @@ ++import { lookup } from "node:dns/promises"; ++import { realpath } from "node:fs/promises"; ++import { isIP } from "node:net"; ++import { dirname, resolve } from "node:path"; ++import type { ServerConfig } from "./config.js"; ++import { isPathInsideRoot } from "./roots.js"; ++import { redactSensitiveText, safeRealFile } from "./safe-inspection.js"; ++import { ++ buildBoundedPayload, ++ measuredPayload, ++ takeWithinCharacterBudget, ++ type ToolMetrics, ++} from "./tool-metrics.js"; ++ ++export interface DesignAuditInput { ++ workspaceRoot: string; ++ url: string; ++ desktopViewport?: { width: number; height: number }; ++ mobileViewport?: { width: number; height: number }; ++ routes?: string[]; ++ checks?: string[]; ++ outputDirectory?: string; ++} ++ ++export interface DesignAuditArtifact { ++ kind: "desktop-screenshot" | "mobile-screenshot" | "report"; ++ path: string; ++} ++ ++export interface DesignAuditAdapterResult { ++ artifacts: DesignAuditArtifact[]; ++ consoleErrors: number; ++ overflowIssues: number; ++ accessibilityIssues: number; ++ headingIssues: number; ++ diagnostics: string[]; ++} ++ ++export interface DesignAuditAdapter { ++ readonly name: string; ++ availability(): Promise<{ available: boolean; diagnostic?: string }>; ++ run(input: DesignAuditInput & { ++ validatedUrl: URL; ++ outputDirectory: string; ++ validateNavigationUrl(value: string): Promise; ++ }): Promise; ++} ++ ++export interface DesignAuditResult extends Record { ++ status: "disabled" | "unavailable" | "completed"; ++ adapter: string; ++ validatedUrl?: string; ++ artifacts: DesignAuditArtifact[]; ++ diagnostics: string[]; ++ consoleErrors?: number; ++ overflowIssues?: number; ++ accessibilityIssues?: number; ++ headingIssues?: number; ++ metrics: ToolMetrics; ++} ++ ++export class UnavailableDesignAuditAdapter implements DesignAuditAdapter { ++ readonly name = "unavailable"; ++ ++ async availability(): Promise<{ available: false; diagnostic: string }> { ++ return { ++ available: false, ++ diagnostic: "No Playwright, Chrome DevTools, Browser MCP runtime bridge, or accessibility engine is available.", ++ }; ++ } ++ ++ async run(): Promise { ++ throw new Error("Design audit adapter is unavailable."); ++ } ++} ++ ++export async function runDesignAudit( ++ config: ServerConfig, ++ input: DesignAuditInput, ++ adapter: DesignAuditAdapter = new UnavailableDesignAuditAdapter(), ++): Promise { ++ const startedAt = performance.now(); ++ if (!config.designAudit) { ++ return measuredPayload({ ++ status: "disabled" as const, ++ adapter: safeAdapterName(adapter.name), ++ artifacts: [], ++ diagnostics: ["Design audit is disabled. Set DEVSPACE_DESIGN_AUDIT=1 to expose the adapter."], ++ }, { ++ startedAt, ++ returnedItems: 0, ++ truncated: false, ++ }); ++ } ++ ++ const validatedUrl = await validateDesignAuditUrl(input.url, config.designAuditAllowedHosts); ++ const validatedRoutes = validateRoutes(validatedUrl, input.routes); ++ const outputDirectory = await validateOutputDirectory(config, input.workspaceRoot, input.outputDirectory); ++ const availability = await adapter.availability(); ++ if (!availability.available) { ++ return measuredPayload({ ++ status: "unavailable" as const, ++ adapter: safeAdapterName(adapter.name), ++ validatedUrl: safeUrlForOutput(validatedUrl), ++ artifacts: [], ++ diagnostics: [safeDiagnostic(availability.diagnostic ?? "Design audit adapter is unavailable.")], ++ }, { ++ startedAt, ++ returnedItems: 0, ++ truncated: false, ++ }); ++ } ++ ++ const result = await adapter.run({ ++ ...input, ++ routes: validatedRoutes, ++ validatedUrl, ++ outputDirectory, ++ validateNavigationUrl: (value) => validateDesignAuditUrl(value, config.designAuditAllowedHosts), ++ }); ++ const artifacts: DesignAuditArtifact[] = []; ++ let invalidArtifacts = 0; ++ for (const artifact of result.artifacts.slice(0, 50)) { ++ const candidate = resolve(outputDirectory, artifact.path); ++ const realFile = await safeRealFile(candidate, outputDirectory); ++ if (!realFile) { ++ invalidArtifacts += 1; ++ continue; ++ } ++ artifacts.push({ kind: artifact.kind, path: realFile }); ++ } ++ const diagnostics = [ ++ ...result.diagnostics.slice(0, 50).map(safeDiagnostic), ++ ...(invalidArtifacts > 0 ? [`Ignored ${invalidArtifacts} artifact path(s) outside the audit output directory.`] : []), ++ ]; ++ const counts = { ++ consoleErrors: safeCount(result.consoleErrors), ++ overflowIssues: safeCount(result.overflowIssues), ++ accessibilityIssues: safeCount(result.accessibilityIssues), ++ headingIssues: safeCount(result.headingIssues), ++ }; ++ ++ return buildBoundedPayload({ ++ startedAt, ++ maxCharacters: 12_000, ++ build: (contentBudget) => { ++ const boundedArtifacts = takeWithinCharacterBudget(artifacts, Math.floor(contentBudget * 0.55)); ++ const boundedDiagnostics = takeWithinCharacterBudget(diagnostics, Math.floor(contentBudget * 0.35)); ++ const truncated = result.artifacts.length > 50 ++ || result.diagnostics.length > 50 ++ || invalidArtifacts > 0 ++ || boundedArtifacts.truncated ++ || boundedDiagnostics.truncated; ++ return { ++ payload: { ++ status: "completed" as const, ++ adapter: safeAdapterName(adapter.name), ++ validatedUrl: safeUrlForOutput(validatedUrl), ++ artifacts: boundedArtifacts.items, ++ diagnostics: boundedDiagnostics.items, ++ ...counts, ++ }, ++ returnedItems: boundedArtifacts.items.length ++ + counts.consoleErrors ++ + counts.overflowIssues ++ + counts.accessibilityIssues ++ + counts.headingIssues, ++ truncated, ++ }; ++ }, ++ }); ++} ++ ++export async function validateDesignAuditUrl( ++ value: string, ++ allowedHosts: string[], ++): Promise { ++ let url: URL; ++ try { ++ url = new URL(value); ++ } catch { ++ throw new Error("Design audit URL must be an absolute HTTP(S) URL."); ++ } ++ if (url.protocol !== "http:" && url.protocol !== "https:") { ++ throw new Error("Design audit URL must use http or https."); ++ } ++ if (url.username || url.password) { ++ throw new Error("Design audit URL must not contain credentials."); ++ } ++ ++ const hostname = normalizeHostname(url.hostname); ++ const allowed = allowedHosts.some((entry) => { ++ const normalized = entry.trim().toLowerCase(); ++ if (!normalized) return false; ++ if (normalized.includes("://")) { ++ try { ++ return new URL(normalized).origin === url.origin; ++ } catch { ++ return false; ++ } ++ } ++ return normalizeHostname(normalized) === hostname; ++ }); ++ if (!allowed) throw new Error(`Design audit URL host is not allowed: ${hostname}`); ++ if (hostname === "0.0.0.0" || hostname === "metadata.google.internal") { ++ throw new Error(`Design audit URL host is unsafe: ${hostname}`); ++ } ++ if (hostname.startsWith("::ffff:")) { ++ throw new Error(`Design audit URL host uses an unsafe mapped address: ${hostname}`); ++ } ++ ++ const addresses = isIP(hostname) ++ ? [{ address: hostname }] ++ : await lookup(hostname, { all: true, verbatim: true }); ++ const loopbackHost = hostname === "localhost" || isLoopbackAddress(hostname); ++ const unsafeResolution = addresses.length === 0 || addresses.some(({ address }) => ++ loopbackHost ? !isLoopbackAddress(address) : isPrivateOrMetadataAddress(address)); ++ if (unsafeResolution) { ++ throw new Error(`Design audit URL resolves to a private or unsafe address: ${hostname}`); ++ } ++ return url; ++} ++ ++async function validateOutputDirectory( ++ config: ServerConfig, ++ workspaceRoot: string, ++ value: string | undefined, ++): Promise { ++ if (!value) return resolve(config.stateDir, "design-audits"); ++ const directory = resolve(workspaceRoot, value); ++ if (!isPathInsideRoot(directory, workspaceRoot)) { ++ throw new Error("Design audit outputDirectory must stay inside the workspace root."); ++ } ++ const realWorkspaceRoot = await realpath(workspaceRoot); ++ let existing = directory; ++ for (;;) { ++ try { ++ const realExisting = await realpath(existing); ++ if (!isPathInsideRoot(realExisting, realWorkspaceRoot)) { ++ throw new Error("Design audit outputDirectory resolves outside the workspace root."); ++ } ++ break; ++ } catch (error) { ++ if (error instanceof Error && error.message.includes("resolves outside")) throw error; ++ const parent = dirname(existing); ++ if (parent === existing) throw new Error("Unable to validate design audit outputDirectory."); ++ existing = parent; ++ } ++ } ++ return directory; ++} ++ ++function validateRoutes(baseUrl: URL, routes: string[] | undefined): string[] | undefined { ++ if (!routes) return undefined; ++ return routes.slice(0, 20).map((route) => { ++ let resolved: URL; ++ try { ++ resolved = new URL(route, baseUrl); ++ } catch { ++ throw new Error(`Invalid design audit route: ${route}`); ++ } ++ if (resolved.origin !== baseUrl.origin || resolved.username || resolved.password) { ++ throw new Error(`Design audit route must stay on the validated origin: ${route}`); ++ } ++ return `${resolved.pathname}${resolved.search}`; ++ }); ++} ++ ++function normalizeHostname(hostname: string): string { ++ return hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); ++} ++ ++function isPrivateOrMetadataAddress(address: string): boolean { ++ const normalized = normalizeHostname(address); ++ const mapped = mappedIpv4(normalized); ++ const ipv4 = mapped ?? normalized; ++ const parts = ipv4.split(".").map(Number); ++ if (parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) { ++ const [a, b, c] = parts as [number, number, number, number]; ++ return a === 0 ++ || a === 10 ++ || a === 127 ++ || a >= 224 ++ || (a === 100 && b >= 64 && b <= 127) ++ || (a === 169 && b === 254) ++ || (a === 172 && b >= 16 && b <= 31) ++ || (a === 192 && b === 0 && c === 0) ++ || (a === 192 && b === 0 && c === 2) ++ || (a === 192 && b === 88 && c === 99) ++ || (a === 192 && b === 168) ++ || (a === 198 && (b === 18 || b === 19)) ++ || (a === 198 && b === 51 && c === 100) ++ || (a === 203 && b === 0 && c === 113); ++ } ++ ++ if (isIP(normalized) === 6) { ++ if (normalized === "::" || normalized === "::1") return true; ++ if (/^fe[89a-f]/.test(normalized) || /^(?:fc|fd|ff)/.test(normalized)) return true; ++ if (normalized.startsWith("2001:db8:")) return true; ++ return !/^[23][0-9a-f]{0,3}:/.test(normalized); ++ } ++ ++ return true; ++} ++ ++function isLoopbackAddress(address: string): boolean { ++ const normalized = normalizeHostname(address); ++ if (normalized === "::1") return true; ++ const ipv4 = mappedIpv4(normalized) ?? normalized; ++ if (!/^\d+\.\d+\.\d+\.\d+$/.test(ipv4)) return false; ++ return Number(ipv4.split(".")[0]) === 127; ++} ++ ++function mappedIpv4(address: string): string | undefined { ++ const dotted = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1]; ++ if (dotted) return dotted; ++ const hexadecimal = address.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); ++ if (!hexadecimal) return undefined; ++ const high = Number.parseInt(hexadecimal[1]!, 16); ++ const low = Number.parseInt(hexadecimal[2]!, 16); ++ return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`; ++} ++ ++function safeAdapterName(name: string): string { ++ return redactSensitiveText(String(name)).slice(0, 100); ++} ++ ++function safeDiagnostic(value: string): string { ++ return redactSensitiveText(String(value)).replace(/\s+/g, " ").trim().slice(0, 500); ++} ++ ++function safeCount(value: number): number { ++ return Number.isFinite(value) ? Math.max(0, Math.min(1_000_000, Math.floor(value))) : 0; ++} ++ ++function safeUrlForOutput(url: URL): string { ++ return `${url.origin}${url.pathname}`; ++} +diff --git a/src/design-audit.test.ts b/src/design-audit.test.ts +new file mode 100644 +index 0000000..479048e +--- /dev/null ++++ b/src/design-audit.test.ts +@@ -0,0 +1,120 @@ ++import assert from "node:assert/strict"; ++import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import { loadConfig } from "./config.js"; ++import { ++ runDesignAudit, ++ validateDesignAuditUrl, ++ type DesignAuditAdapter, ++} from "./design-audit.js"; ++ ++const root = await mkdtemp(join(tmpdir(), "gpt-agent-design-audit-test-")); ++const baseEnv = { ++ DEVSPACE_ALLOWED_ROOTS: root, ++ DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", ++}; ++ ++try { ++ const disabled = await runDesignAudit(loadConfig(baseEnv), { ++ workspaceRoot: root, ++ url: "not-a-url", ++ }); ++ assert.equal(disabled.status, "disabled"); ++ assert.equal(disabled.metrics.payloadCharacters, JSON.stringify(disabled).length); ++ ++ const enabledConfig = loadConfig({ ...baseEnv, DEVSPACE_DESIGN_AUDIT: "1" }); ++ const unavailable = await runDesignAudit(enabledConfig, { ++ workspaceRoot: root, ++ url: "http://localhost:3000/path?token=hidden#fragment", ++ }); ++ assert.equal(unavailable.status, "unavailable"); ++ assert.equal(unavailable.validatedUrl, "http://localhost:3000/path"); ++ assert.equal(JSON.stringify(unavailable).includes("hidden"), false); ++ assert.equal(unavailable.metrics.payloadCharacters, JSON.stringify(unavailable).length); ++ ++ await assert.rejects( ++ () => validateDesignAuditUrl("file:///tmp/index.html", ["localhost"]), ++ /http or https/, ++ ); ++ await assert.rejects( ++ () => validateDesignAuditUrl("http://user:pass@localhost:3000", ["localhost"]), ++ /must not contain credentials/, ++ ); ++ await assert.rejects( ++ () => validateDesignAuditUrl("http://192.168.1.2", ["localhost"]), ++ /not allowed/, ++ ); ++ await assert.rejects( ++ () => validateDesignAuditUrl("http://[fe90::1]", ["fe90::1"]), ++ /private or unsafe/, ++ ); ++ await assert.rejects( ++ () => validateDesignAuditUrl("http://[::ffff:7f00:1]", ["::ffff:7f00:1"]), ++ /unsafe mapped address/, ++ ); ++ await assert.rejects( ++ () => validateDesignAuditUrl("http://198.18.0.1", ["198.18.0.1"]), ++ /private or unsafe/, ++ ); ++ await assert.rejects( ++ () => validateDesignAuditUrl("http://224.0.0.1", ["224.0.0.1"]), ++ /private or unsafe/, ++ ); ++ await assert.rejects( ++ () => runDesignAudit(enabledConfig, { ++ workspaceRoot: root, ++ url: "http://localhost:3000", ++ outputDirectory: "../escape", ++ }), ++ /must stay inside/, ++ ); ++ await assert.rejects( ++ () => runDesignAudit(enabledConfig, { ++ workspaceRoot: root, ++ url: "http://localhost:3000", ++ routes: ["//example.com/private"], ++ }), ++ /must stay on the validated origin/, ++ ); ++ ++ const outputDirectory = join(root, "audit-output"); ++ const artifactPath = join(outputDirectory, "desktop.png"); ++ await mkdir(outputDirectory); ++ await writeFile(artifactPath, "image fixture"); ++ const adapter: DesignAuditAdapter = { ++ name: "fake-adapter", ++ async availability() { return { available: true }; }, ++ async run() { ++ return { ++ artifacts: [ ++ ...Array.from({ length: 55 }, () => ({ ++ kind: "desktop-screenshot" as const, ++ path: artifactPath, ++ })), ++ { kind: "report" as const, path: join(root, "outside.txt") }, ++ ], ++ consoleErrors: 2, ++ overflowIssues: 1, ++ accessibilityIssues: 3, ++ headingIssues: 1, ++ diagnostics: Array.from( ++ { length: 55 }, ++ () => "api_key=very-secret-value-that-must-not-leak", ++ ), ++ }; ++ }, ++ }; ++ const completed = await runDesignAudit(enabledConfig, { ++ workspaceRoot: root, ++ url: "http://localhost:3000", ++ outputDirectory: "audit-output", ++ }, adapter); ++ assert.equal(completed.status, "completed"); ++ assert.equal(completed.metrics.truncated, true); ++ assert.ok(completed.metrics.payloadCharacters <= 12_000); ++ assert.equal(completed.metrics.payloadCharacters, JSON.stringify(completed).length); ++ assert.equal(JSON.stringify(completed).includes("very-secret-value"), false); ++} finally { ++ await rm(root, { recursive: true, force: true }); ++} +diff --git a/agents/design.md b/agents/design.md +new file mode 100644 +index 0000000..a3a09e9 +--- /dev/null ++++ b/agents/design.md +@@ -0,0 +1,15 @@ ++--- ++schema: devspace-agent/v1 ++name: design ++description: Read-only UI and UX audit specialist requiring real rendered evidence. ++provider: codex ++write-mode: read_only ++thinking: high ++--- ++ ++Audit visual hierarchy, spacing, typography, responsiveness, mobile usability, accessibility, ++contrast, focus/hover/selected states, overflow, and design-system consistency. Use design_audit ++artifacts when available. If real screenshots or rendered evidence are unavailable, report the ++audit as unverified and do not issue a passing judgment. ++ ++Return evidence, findings, affected routes/components, and the next validation action. +diff --git a/agents/explore.md b/agents/explore.md +new file mode 100644 +index 0000000..70abf44 +--- /dev/null ++++ b/agents/explore.md +@@ -0,0 +1,22 @@ ++--- ++schema: devspace-agent/v1 ++name: explore ++description: Read-only specialist for structure, root-cause, and impact investigation. ++provider: codex ++write-mode: read_only ++thinking: high ++--- ++ ++Investigate without editing, staging, committing, pushing, deploying, or changing external state. ++Prefer CodeGraph when available, then focused search and only necessary reads. Do not perform broad scans. ++ ++Return: ++ ++```text ++rootCause: ++relevantFiles: ++relevantSymbols: ++impactRadius: ++evidence: ++nextAction: ++``` +diff --git a/agents/implement.md b/agents/implement.md +new file mode 100644 +index 0000000..638397e +--- /dev/null ++++ b/agents/implement.md +@@ -0,0 +1,22 @@ ++--- ++schema: devspace-agent/v1 ++name: implement ++description: Focused implementation specialist for verified, minimal code changes. ++provider: codex ++write-mode: allowed ++thinking: high ++--- ++ ++Confirm the root cause, then implement the smallest correct change. Preserve unrelated and ++uncommitted work. Do not add dependencies without proof, and do not deploy, push, commit, or ++repeat builds/tests without a specific reason. ++ ++Return: ++ ++```text ++rootCause: ++changes: ++tests: ++risks: ++remaining: ++``` +diff --git a/agents/review.md b/agents/review.md +new file mode 100644 +index 0000000..9739d9d +--- /dev/null ++++ b/agents/review.md +@@ -0,0 +1,15 @@ ++--- ++schema: devspace-agent/v1 ++name: review ++description: Read-only diff reviewer for correctness, security, regression, and scope risk. ++provider: codex ++write-mode: read_only ++thinking: high ++--- ++ ++Review without editing or changing Git state. Inspect correctness, security, type safety, ++performance, regressions, scope creep, dependencies, permission boundaries, secret exposure, ++payload growth, and compact-mode regressions. ++ ++Return findings in severity order with evidence, affected files, and a recommended fix. ++Explicitly state when no actionable issue is found. +diff --git a/skills/design-system-audit/SKILL.md b/skills/design-system-audit/SKILL.md +new file mode 100644 +index 0000000..6366bcc +--- /dev/null ++++ b/skills/design-system-audit/SKILL.md +@@ -0,0 +1,44 @@ ++--- ++name: design-system-audit ++description: Audit a rendered web interface for visual hierarchy, spacing, typography, component consistency, and interaction states. Use for design-system compliance or UI consistency reviews. ++--- ++ ++# Design System Audit ++ ++## Triggers ++ ++- design system audit ++- visual consistency review ++- component state review ++ ++## Inputs ++ ++- Rendered desktop and mobile evidence, routes, and target components. ++- Existing tokens, components, or design-system rules when available. ++ ++## Steps ++ ++1. Obtain real screenshots or `design_audit` artifacts before judging appearance. ++2. Compare hierarchy, spacing, typography, color, components, focus, hover, and selected states. ++3. Separate verified findings from items that require another state or viewport. ++4. Rank findings by user impact and cite the route, component, and evidence. ++ ++## Outputs ++ ++- Severity-ordered findings, evidence, affected routes/components, and recommended fixes. ++- An explicit `verified` or `unverified` result. ++ ++## Completion criteria ++ ++- Inspect representative desktop and mobile states and all interaction states in scope. ++- Do not pass the audit when rendered evidence is unavailable. ++ ++## Not included ++ ++- Rebranding, speculative redesign, or code changes unless separately requested. ++ ++## Required tools ++ ++- `design_audit` ++- `focused_context` ++- `read` +diff --git a/skills/design-system-audit/agents/openai.yaml b/skills/design-system-audit/agents/openai.yaml +new file mode 100644 +index 0000000..f353dcc +--- /dev/null ++++ b/skills/design-system-audit/agents/openai.yaml +@@ -0,0 +1,4 @@ ++interface: ++ display_name: "Design System Audit" ++ short_description: "Audit visual consistency and interaction states" ++ default_prompt: "Use $design-system-audit to audit this rendered interface against its design system." +diff --git a/skills/product-ui-review/SKILL.md b/skills/product-ui-review/SKILL.md +new file mode 100644 +index 0000000..69e59af +--- /dev/null ++++ b/skills/product-ui-review/SKILL.md +@@ -0,0 +1,43 @@ ++--- ++name: product-ui-review ++description: Review rendered product flows, dashboards, and marketing pages for clarity, states, usability, consistency, and before/after quality. Use for holistic UI or UX reviews. ++--- ++ ++# Product UI Review ++ ++## Triggers ++ ++- product UI review ++- dashboard UI review ++- marketing page review ++ ++## Inputs ++ ++- Target routes, user goal, critical states, supported viewports, and existing UI conventions. ++ ++## Steps ++ ++1. Capture rendered evidence for the critical flow and meaningful states. ++2. Review hierarchy, content clarity, navigation, feedback, empty/error/loading states, and mobile usability. ++3. Compare against existing product patterns before proposing changes. ++4. If reviewing a change, collect before/after evidence under equivalent conditions. ++ ++## Outputs ++ ++- Prioritized findings, evidence, affected flow, recommended action, and validation gaps. ++ ++## Completion criteria ++ ++- Cover the critical path and relevant error, loading, empty, hover, focus, and selected states. ++- Do not claim success without real rendered evidence. ++ ++## Not included ++ ++- Product strategy, user research, or implementation unless separately requested. ++ ++## Required tools ++ ++- `design_audit` ++- `project_snapshot` ++- `focused_context` ++- `read` +diff --git a/skills/product-ui-review/agents/openai.yaml b/skills/product-ui-review/agents/openai.yaml +new file mode 100644 +index 0000000..23185ff +--- /dev/null ++++ b/skills/product-ui-review/agents/openai.yaml +@@ -0,0 +1,4 @@ ++interface: ++ display_name: "Product UI Review" ++ short_description: "Review product UI flows with rendered evidence" ++ default_prompt: "Use $product-ui-review to review this rendered product flow and report prioritized findings." +diff --git a/skills/responsive-accessibility-audit/SKILL.md b/skills/responsive-accessibility-audit/SKILL.md +new file mode 100644 +index 0000000..13dc2a3 +--- /dev/null ++++ b/skills/responsive-accessibility-audit/SKILL.md +@@ -0,0 +1,43 @@ ++--- ++name: responsive-accessibility-audit ++description: Validate rendered web layouts across desktop and mobile for overflow, usability, semantics, keyboard access, focus, headings, and contrast. Use for responsive or accessibility audits. ++--- ++ ++# Responsive Accessibility Audit ++ ++## Triggers ++ ++- responsive layout audit ++- accessibility audit ++- mobile usability review ++ ++## Inputs ++ ++- URLs/routes, desktop and mobile viewports, target flows, and expected breakpoints. ++ ++## Steps ++ ++1. Capture real desktop and mobile evidence with `design_audit` or an equivalent browser tool. ++2. Check overflow, reflow, touch targets, keyboard order, focus visibility, headings, labels, and contrast. ++3. Correlate runtime evidence with the smallest relevant source set. ++4. Report inaccessible states and the viewport or interaction that reproduces each issue. ++ ++## Outputs ++ ++- Findings with severity, evidence, affected viewport/route, and remediation guidance. ++- Diagnostics for checks that could not run. ++ ++## Completion criteria ++ ++- Validate both desktop and mobile evidence and at least the default keyboard path. ++- Mark the result `unverified`; never pass it from source inspection alone. ++ ++## Not included ++ ++- Formal legal conformance certification or automated code modification. ++ ++## Required tools ++ ++- `design_audit` ++- `focused_context` ++- `read` +diff --git a/skills/responsive-accessibility-audit/agents/openai.yaml b/skills/responsive-accessibility-audit/agents/openai.yaml +new file mode 100644 +index 0000000..5c53e60 +--- /dev/null ++++ b/skills/responsive-accessibility-audit/agents/openai.yaml +@@ -0,0 +1,4 @@ ++interface: ++ display_name: "Responsive Accessibility Audit" ++ short_description: "Check responsive layout and accessibility" ++ default_prompt: "Use $responsive-accessibility-audit to validate desktop, mobile, and accessibility evidence." diff --git a/compatibility-kit/openai-model-compatibility-2026-07/verify.mjs b/compatibility-kit/openai-model-compatibility-2026-07/verify.mjs new file mode 100644 index 00000000..463dbf11 --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/verify.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; + +const args = process.argv.slice(2); +const repoArgIndex = args.indexOf("--repo"); +const repoRoot = resolve(repoArgIndex >= 0 ? args[repoArgIndex + 1] ?? "" : process.cwd()); +const skipInstall = args.includes("--skip-install"); + +function run(command, commandArgs, capture = false) { + const result = spawnSync(command, commandArgs, { + cwd: repoRoot, + encoding: "utf8", + stdio: capture ? "pipe" : "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = capture + ? [result.stdout, result.stderr].filter(Boolean).join("\n").trim() + : ""; + throw new Error(detail || `${command} ${commandArgs.join(" ")} failed.`); + } + return result.stdout ?? ""; +} + +const packageJson = JSON.parse(await readFile(resolve(repoRoot, "package.json"), "utf8")); +if (packageJson.name !== "@waishnav/devspace") { + throw new Error("Verification must run in a DevSpace repository checkout."); +} + +const changed = run( + "git", + ["diff", "--name-only", "--diff-filter=ACMRT", "HEAD"], + true, +) + .split("\n") + .filter(Boolean); +const untracked = run("git", ["ls-files", "--others", "--exclude-standard"], true) + .split("\n") + .filter(Boolean); +const candidateFiles = [...new Set([...changed, ...untracked])]; +const forbiddenMarkers = [ + "/Users/", + "/home/", + "BEGIN OPENSSH PRIVATE KEY", + "gho_", + "sk-proj-", + "private-user-images.githubusercontent.com", +]; + +for (const relativePath of candidateFiles) { + let content; + try { + content = await readFile(resolve(repoRoot, relativePath), "utf8"); + } catch { + continue; + } + for (const marker of forbiddenMarkers) { + if (content.includes(marker)) { + throw new Error(`Public-data check failed in ${relativePath}: forbidden marker detected.`); + } + } +} + +if (!skipInstall) run("npm", ["ci"]); +run("npm", ["run", "typecheck"]); +run("npm", ["test"]); +run("npm", ["run", "build"]); + +console.log("Verification passed: privacy markers, typecheck, tests, and build are clean."); diff --git a/docs/configuration.md b/docs/configuration.md index 4c02eb1a..cd4bfa6c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,9 +83,66 @@ sessions. | Value | Behavior | | --- | --- | -| `full` | Default. Widget UI is attached to exposed workspace, file, edit, and shell tools. | +| `full` | Widget UI is attached to exposed workspace, file, edit, and shell tools. This is the default when full workspace payloads are selected. | | `changes` | Enables the aggregate `show_changes` tool and attaches widget UI to `open_workspace` and `show_changes`. | -| `off` | Disables widget UI. | +| `off` | Disables widget UI. This is the default in compact mode. | + +## Compact payloads and usage estimates + +| Variable | Default | Purpose | +| --- | --- | --- | +| `DEVSPACE_OPEN_WORKSPACE_PAYLOAD` | `compact` | Use `compact` for bounded instruction excerpts and smaller skill metadata, or `full` for the v1.0 payload. | +| `DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS` | `6000` | Maximum characters returned per instruction excerpt; minimum `256`. | +| `DEVSPACE_USAGE_CONTENT` | `compact` | Append `compact` or `full` observed-token estimates to tool output, or use `off` to hide them. | +| `DEVSPACE_USAGE_HISTORY` | `~/.local/share/devspace/usage-history.jsonl` | Local JSONL destination for non-blocking usage diagnostics. | + +Token counts are estimates from text handled by DevSpace, not ChatGPT model billing or actual model usage. + +## DevSpace v1.1 feature flags + +All v1.1 feature flags default to `0`, so the existing compact Tool catalog and Fast Path remain unchanged. + +| Variable | Enables | +| --- | --- | +| `DEVSPACE_SKILL_MATCHER` | `match_skills`, which ranks bounded Skill metadata without loading bodies. | +| `DEVSPACE_COMPOUND_TOOLS` | `project_snapshot`, `focused_context`, and read-only `review_changes`. | +| `DEVSPACE_BUILTIN_PROFILES` | Built-in `explore`, `implement`, `review`, and `design` profiles when Subagents are enabled. | +| `DEVSPACE_DESIGN_AUDIT` | The guarded `design_audit` adapter and three bundled Design Skills. | +| `DEVSPACE_DESIGN_AUDIT_ALLOWED_HOSTS` | Comma-separated exact hosts/origins; defaults to loopback hosts only. | + +Feature flag values are strict: `1/0`, `true/false`, `yes/no`, and `on/off` are accepted. +New Tool results include `serverDurationMs`, `payloadCharacters`, `returnedItems`, and `truncated`. + +The v1.1 package intentionally ships Design Audit as an adapter only: no Playwright/CDP/axe +runtime is currently bundled, no browser binary is downloaded, and an enabled Tool returns an +unavailable error until a real adapter is connected. A future adapter must use an ephemeral +browser, validate redirects and subresources against the same URL policy, avoid cookies, write +artifacts only inside the requested workspace directory, and terminate the browser in `finally`. + +Skills may optionally declare bounded `short-description`, `triggers`, and `required-tools` +frontmatter for matching. Existing `name` and `description` metadata remains fully compatible; +only a selected Skill is activated by reading its `SKILL.md`. + +## Approved shell aliases + +`DEVSPACE_APPROVED_SHELL_COMMANDS_FILE` may point to a local JSON file. The +default is `~/.devspace/approved-shell-commands.json`. An alias is invoked as +`devspace-approved ` and is accepted only when its configured +`workspaceRoot` exactly matches the open workspace. Its optional +`workingDirectory` must remain inside that root. + +```json +{ + "commands": [ + { + "alias": "verify", + "workspaceRoot": "/absolute/project/path", + "workingDirectory": ".", + "command": "npm test" + } + ] +} +``` ## Skills diff --git a/package.json b/package.json index af895b37..b6ad4b4f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dist", "docs", "examples", + "agents", "scripts", "skills", "README.md" @@ -28,7 +29,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/usage-meter.test.ts && tsx src/pi-tools.test.ts && tsx src/skill-matcher.test.ts && tsx src/compound-tools.test.ts && tsx src/design-audit.test.ts && tsx src/register-v11-tools.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/skills/design-system-audit/SKILL.md b/skills/design-system-audit/SKILL.md new file mode 100644 index 00000000..6366bcc0 --- /dev/null +++ b/skills/design-system-audit/SKILL.md @@ -0,0 +1,44 @@ +--- +name: design-system-audit +description: Audit a rendered web interface for visual hierarchy, spacing, typography, component consistency, and interaction states. Use for design-system compliance or UI consistency reviews. +--- + +# Design System Audit + +## Triggers + +- design system audit +- visual consistency review +- component state review + +## Inputs + +- Rendered desktop and mobile evidence, routes, and target components. +- Existing tokens, components, or design-system rules when available. + +## Steps + +1. Obtain real screenshots or `design_audit` artifacts before judging appearance. +2. Compare hierarchy, spacing, typography, color, components, focus, hover, and selected states. +3. Separate verified findings from items that require another state or viewport. +4. Rank findings by user impact and cite the route, component, and evidence. + +## Outputs + +- Severity-ordered findings, evidence, affected routes/components, and recommended fixes. +- An explicit `verified` or `unverified` result. + +## Completion criteria + +- Inspect representative desktop and mobile states and all interaction states in scope. +- Do not pass the audit when rendered evidence is unavailable. + +## Not included + +- Rebranding, speculative redesign, or code changes unless separately requested. + +## Required tools + +- `design_audit` +- `focused_context` +- `read` diff --git a/skills/design-system-audit/agents/openai.yaml b/skills/design-system-audit/agents/openai.yaml new file mode 100644 index 00000000..f353dccc --- /dev/null +++ b/skills/design-system-audit/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Design System Audit" + short_description: "Audit visual consistency and interaction states" + default_prompt: "Use $design-system-audit to audit this rendered interface against its design system." diff --git a/skills/product-ui-review/SKILL.md b/skills/product-ui-review/SKILL.md new file mode 100644 index 00000000..69e59af2 --- /dev/null +++ b/skills/product-ui-review/SKILL.md @@ -0,0 +1,43 @@ +--- +name: product-ui-review +description: Review rendered product flows, dashboards, and marketing pages for clarity, states, usability, consistency, and before/after quality. Use for holistic UI or UX reviews. +--- + +# Product UI Review + +## Triggers + +- product UI review +- dashboard UI review +- marketing page review + +## Inputs + +- Target routes, user goal, critical states, supported viewports, and existing UI conventions. + +## Steps + +1. Capture rendered evidence for the critical flow and meaningful states. +2. Review hierarchy, content clarity, navigation, feedback, empty/error/loading states, and mobile usability. +3. Compare against existing product patterns before proposing changes. +4. If reviewing a change, collect before/after evidence under equivalent conditions. + +## Outputs + +- Prioritized findings, evidence, affected flow, recommended action, and validation gaps. + +## Completion criteria + +- Cover the critical path and relevant error, loading, empty, hover, focus, and selected states. +- Do not claim success without real rendered evidence. + +## Not included + +- Product strategy, user research, or implementation unless separately requested. + +## Required tools + +- `design_audit` +- `project_snapshot` +- `focused_context` +- `read` diff --git a/skills/product-ui-review/agents/openai.yaml b/skills/product-ui-review/agents/openai.yaml new file mode 100644 index 00000000..23185ffb --- /dev/null +++ b/skills/product-ui-review/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Product UI Review" + short_description: "Review product UI flows with rendered evidence" + default_prompt: "Use $product-ui-review to review this rendered product flow and report prioritized findings." diff --git a/skills/responsive-accessibility-audit/SKILL.md b/skills/responsive-accessibility-audit/SKILL.md new file mode 100644 index 00000000..13dc2a3c --- /dev/null +++ b/skills/responsive-accessibility-audit/SKILL.md @@ -0,0 +1,43 @@ +--- +name: responsive-accessibility-audit +description: Validate rendered web layouts across desktop and mobile for overflow, usability, semantics, keyboard access, focus, headings, and contrast. Use for responsive or accessibility audits. +--- + +# Responsive Accessibility Audit + +## Triggers + +- responsive layout audit +- accessibility audit +- mobile usability review + +## Inputs + +- URLs/routes, desktop and mobile viewports, target flows, and expected breakpoints. + +## Steps + +1. Capture real desktop and mobile evidence with `design_audit` or an equivalent browser tool. +2. Check overflow, reflow, touch targets, keyboard order, focus visibility, headings, labels, and contrast. +3. Correlate runtime evidence with the smallest relevant source set. +4. Report inaccessible states and the viewport or interaction that reproduces each issue. + +## Outputs + +- Findings with severity, evidence, affected viewport/route, and remediation guidance. +- Diagnostics for checks that could not run. + +## Completion criteria + +- Validate both desktop and mobile evidence and at least the default keyboard path. +- Mark the result `unverified`; never pass it from source inspection alone. + +## Not included + +- Formal legal conformance certification or automated code modification. + +## Required tools + +- `design_audit` +- `focused_context` +- `read` diff --git a/skills/responsive-accessibility-audit/agents/openai.yaml b/skills/responsive-accessibility-audit/agents/openai.yaml new file mode 100644 index 00000000..5c53e602 --- /dev/null +++ b/skills/responsive-accessibility-audit/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Responsive Accessibility Audit" + short_description: "Check responsive layout and accessibility" + default_prompt: "Use $responsive-accessibility-audit to validate desktop, mobile, and accessibility evidence." diff --git a/src/cli.ts b/src/cli.ts index bbae1ae0..226aec10 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ import { satisfies } from "semver"; import { loadConfig } from "./config.js"; import { runLocalAgentProvider } from "./local-agent-adapters.js"; import { + assertLocalAgentProfileBoundary, isLocalAgentProvider, loadLocalAgentProfiles, type LocalAgentProfile, @@ -392,6 +393,7 @@ async function runAgentsRun(args: string[]): Promise { `Unknown subagent profile, provider, or id: ${parsed.target}. Available ${formatAvailableLocalAgentTargets(profiles)}`, ); } + if (target.kind === "profile") assertLocalAgentProfileBoundary(target.profile); assertLocalAgentProviderAvailable(target.provider); const promptFile = writeAgentPromptFile(parsed.prompt); @@ -475,13 +477,14 @@ async function runLocalAgentProfile( record: LocalAgentRecord, prompt: string, ): Promise { + assertLocalAgentProfileBoundary(profile); const body = profile.body.trim(); const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; return runLocalAgentProvider(profile.provider, { prompt: fullPrompt, workspace: record.workspaceRoot, providerSessionId: record.providerSessionId, - writeMode: "allowed", + writeMode: profile.writeMode, model: record.model ?? profile.model, thinking: record.thinking ?? profile.thinking, }); diff --git a/src/compound-tools.test.ts b/src/compound-tools.test.ts new file mode 100644 index 00000000..23a46b19 --- /dev/null +++ b/src/compound-tools.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { + focusedContext, + projectSnapshot, + reviewChanges, + type WorkspaceInspectionContext, +} from "./compound-tools.js"; + +const execFileAsync = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "gpt-agent-compound-tools-test-")); + +try { + await mkdir(join(root, "src")); + await mkdir(join(root, "other")); + await writeFile(join(root, "package.json"), JSON.stringify({ + name: "fixture-project", + version: "1.2.3", + scripts: { typecheck: "tsc", test: "test-command", build: "build-command" }, + })); + await writeFile(join(root, "src", "target.ts"), "export function targetSymbol() { return 1; }\n"); + await writeFile(join(root, "other", "outside.ts"), "export const targetSymbol = 2;\n"); + await writeFile(join(root, ".env"), "API_KEY=super-secret-value-that-must-not-leak\n"); + await git(["init"]); + await git(["config", "user.email", "gpt-agent@example.com"]); + await git(["config", "user.name", "GPT Agent"]); + await git(["add", "."]); + await git(["commit", "-m", "fixture"]); + + await writeFile(join(root, "src", "target.ts"), [ + "export function targetSymbol() {", + " const unsafe: any = 2;", + " return unsafe;", + "}", + "", + ].join("\n")); + await writeFile(join(root, ".env"), "API_KEY=changed-secret-value-that-must-not-leak\n"); + + const context: WorkspaceInspectionContext = { + root, + instructionPaths: [join(root, "AGENTS.md")], + skills: Array.from({ length: 80 }, (_, index) => ({ + name: `skill-${index}`, + description: `Skill ${index} description`, + })), + agentProviders: [{ name: "codex", provider: "codex", available: true }], + agentProfiles: [{ name: "review", provider: "codex", writeMode: "read_only" }], + }; + + const snapshot = await projectSnapshot(context, { maxCharacters: 2_000 }); + assert.equal(snapshot.package.name, "fixture-project"); + assert.deepEqual(snapshot.package.scripts.sort(), ["build", "test", "typecheck"]); + assert.equal(snapshot.recommendedTestCommand, "npm test"); + assert.equal(snapshot.recommendedBuildCommand, "npm run build"); + assert.equal(snapshot.metrics.truncated, true); + assert.ok(snapshot.metrics.payloadCharacters <= 2_000); + assert.equal(snapshot.metrics.payloadCharacters, JSON.stringify(snapshot).length); + assert.equal(JSON.stringify(snapshot).includes("super-secret-value"), false); + + const focused = await focusedContext(context, { + focus: "targetSymbol", + paths: ["src"], + maxFiles: 1, + maxCharacters: 4_000, + }); + assert.deepEqual(focused.relevantFiles, ["src/target.ts"]); + assert.equal(focused.relevantFiles.some((path) => path.startsWith("other/")), false); + assert.equal(focused.relevantFiles.length <= 1, true); + assert.equal(focused.metrics.payloadCharacters, JSON.stringify(focused).length); + + const fileBefore = await readFile(join(root, "src", "target.ts")); + const indexBefore = await readFile(join(root, ".git", "index")); + const statusBefore = (await git(["status", "--porcelain=v1", "-z"])).stdout; + const stagedBefore = (await git(["diff", "--cached"])).stdout; + const review = await reviewChanges(context, { maxCharacters: 4_000 }); + assert.ok(review.changedFiles.some((entry) => entry.path === "src/target.ts")); + assert.ok(review.suspiciousChanges.some((entry) => entry.rule === "type_safety_escape")); + assert.equal(JSON.stringify(review).includes("changed-secret-value"), false); + assert.equal(review.metrics.payloadCharacters, JSON.stringify(review).length); + assert.deepEqual(await readFile(join(root, "src", "target.ts")), fileBefore); + assert.deepEqual(await readFile(join(root, ".git", "index")), indexBefore); + assert.equal((await git(["status", "--porcelain=v1", "-z"])).stdout, statusBefore); + assert.equal((await git(["diff", "--cached"])).stdout, stagedBefore); + + await assert.rejects( + () => reviewChanges(context, { baseRef: "--output=/tmp/escape" }), + /Invalid baseRef/, + ); +} finally { + await rm(root, { recursive: true, force: true }); +} + +async function git(args: string[]): Promise<{ stdout: string; stderr: string }> { + return execFileAsync("git", args, { cwd: root, encoding: "utf8" }); +} diff --git a/src/compound-tools.ts b/src/compound-tools.ts new file mode 100644 index 00000000..dc2f24c1 --- /dev/null +++ b/src/compound-tools.ts @@ -0,0 +1,592 @@ +import { execFile } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { opendir, readFile, realpath, stat } from "node:fs/promises"; +import { basename, dirname, relative, resolve, sep } from "node:path"; +import { promisify } from "node:util"; +import { isPathInsideRoot, resolveAllowedPath } from "./roots.js"; +import { + containsSecretValue, + isGeneratedOrBinaryPath, + isSecretLikePath, + safeRealFile, +} from "./safe-inspection.js"; +import { + buildBoundedPayload, + clampInteger, + takeWithinCharacterBudget, + type ToolMetrics, +} from "./tool-metrics.js"; + +const execFileAsync = promisify(execFile); +const MAX_GIT_BUFFER = 1024 * 1024; +const SKIPPED_DIRS = new Set([ + ".git", ".next", ".turbo", "build", "coverage", "dist", "node_modules", +]); + +export interface InspectionSkillSummary { + name: string; + description?: string; +} + +export interface InspectionAgentSummary { + name: string; + provider: string; + available?: boolean; + writeMode?: string; +} + +export interface WorkspaceInspectionContext { + root: string; + instructionPaths: string[]; + skills: InspectionSkillSummary[]; + agentProviders: InspectionAgentSummary[]; + agentProfiles: InspectionAgentSummary[]; +} + +export interface ProjectSnapshotResult extends Record { + branch: string | null; + dirty: boolean; + changedFiles: string[]; + diffStat: string; + package: { + name?: string; + version?: string; + scripts: string[]; + }; + applicableInstructions: string[]; + skills: InspectionSkillSummary[]; + agentProviders: InspectionAgentSummary[]; + agentProfiles: InspectionAgentSummary[]; + codeGraph: { + detected: boolean; + available: false; + reason: "not_initialized" | "adapter_unavailable"; + }; + recommendedTestCommand?: string; + recommendedBuildCommand?: string; + metrics: ToolMetrics; +} + +export async function projectSnapshot( + context: WorkspaceInspectionContext, + options: { maxCharacters?: number } = {}, +): Promise { + const startedAt = performance.now(); + const maxCharacters = clampInteger(options.maxCharacters, 12_000, 2_000, 50_000); + const [branchResult, statusResult, statResult, packageInfo] = await Promise.all([ + git(context.root, ["symbolic-ref", "--quiet", "--short", "HEAD"]), + git(context.root, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]), + git(context.root, ["diff", "--no-ext-diff", "--no-textconv", "--shortstat", "HEAD", "--"]), + readPackageSummary(context.root), + ]); + const rawChangedFiles = statusResult.ok ? parseStatusPaths(statusResult.stdout) : []; + const detected = existsSync(resolve(context.root, ".codegraph")); + + return buildBoundedPayload({ + startedAt, + maxCharacters, + build: (contentBudget) => { + const files = takeWithinCharacterBudget(rawChangedFiles, Math.floor(contentBudget * 0.28)); + const instructions = takeWithinCharacterBudget( + context.instructionPaths.map((path) => displayPath(path, context.root)), + Math.floor(contentBudget * 0.18), + ); + const skills = takeWithinCharacterBudget( + context.skills.map((skill) => ({ + name: skill.name.slice(0, 100), + ...(skill.description ? { description: skill.description.slice(0, 160) } : {}), + })), + Math.floor(contentBudget * 0.18), + ); + const providers = takeWithinCharacterBudget( + context.agentProviders, + Math.floor(contentBudget * 0.13), + ); + const profiles = takeWithinCharacterBudget( + context.agentProfiles, + Math.floor(contentBudget * 0.13), + ); + const truncated = files.truncated + || instructions.truncated + || skills.truncated + || providers.truncated + || profiles.truncated; + const returnedItems = files.items.length + + instructions.items.length + + skills.items.length + + providers.items.length + + profiles.items.length; + + return { + payload: { + branch: branchResult.ok ? branchResult.stdout.trim() || null : null, + dirty: rawChangedFiles.length > 0, + changedFiles: files.items, + diffStat: statResult.ok ? statResult.stdout.trim().slice(0, 500) : "", + package: packageInfo, + applicableInstructions: instructions.items, + skills: skills.items, + agentProviders: providers.items, + agentProfiles: profiles.items, + codeGraph: { + detected, + available: false as const, + reason: detected ? "adapter_unavailable" as const : "not_initialized" as const, + }, + ...(recommendedCommand(packageInfo.scripts, "test") + ? { recommendedTestCommand: recommendedCommand(packageInfo.scripts, "test") } + : {}), + ...(recommendedCommand(packageInfo.scripts, "build") + ? { recommendedBuildCommand: recommendedCommand(packageInfo.scripts, "build") } + : {}), + }, + returnedItems, + truncated, + }; + }, + }); +} + +export interface FocusedContextResult extends Record { + relevantFiles: string[]; + relevantSymbols: Array<{ name: string; path: string; line: number }>; + searchMatches: Array<{ path: string; line: number }>; + applicableInstructions: string[]; + impactCandidates: string[]; + recommendedReads: string[]; + detectionMethod: "bounded_text_fallback" | "codegraph_adapter_unavailable_fallback"; + metrics: ToolMetrics; +} + +export async function focusedContext( + context: WorkspaceInspectionContext, + options: { + focus: string; + paths?: string[]; + maxFiles?: number; + maxCharacters?: number; + }, +): Promise { + const startedAt = performance.now(); + const focus = options.focus.normalize("NFKC").trim().slice(0, 300); + if (!focus) throw new Error("focus is required."); + const maxFiles = clampInteger(options.maxFiles, 8, 1, 25); + const maxCharacters = clampInteger(options.maxCharacters, 12_000, 2_000, 50_000); + const scopes = await resolveScopes(context.root, options.paths); + const candidates = await collectCandidateFiles(context.root, scopes, 500); + const focusTokens = searchableTokens(focus); + candidates.files.sort((a, b) => filenameScore(b, focusTokens) - filenameScore(a, focusTokens)); + + const relevantFiles: string[] = []; + const relevantSymbols: Array<{ name: string; path: string; line: number }> = []; + const searchMatches: Array<{ path: string; line: number }> = []; + let scannedBytes = 0; + let scanTruncated = candidates.truncated || candidates.files.length > 120; + + for (const path of candidates.files.slice(0, 120)) { + if (relevantFiles.length >= maxFiles || scannedBytes >= 3_000_000) { + scanTruncated = true; + break; + } + const content = await readBoundedTextFile(path, context.root, 96_000); + if (content === undefined) continue; + scannedBytes += content.length; + const display = displayPath(path, context.root); + const lines = content.split(/\r?\n/); + let matchedFile = false; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + const normalizedLine = line.normalize("NFKC").toLocaleLowerCase(); + if (!focusTokens.some((token) => normalizedLine.includes(token))) continue; + matchedFile = true; + if (searchMatches.length < maxFiles * 5) { + searchMatches.push({ path: display, line: index + 1 }); + } + const symbol = extractSymbol(line); + if (symbol && relevantSymbols.length < maxFiles * 3) { + relevantSymbols.push({ name: symbol, path: display, line: index + 1 }); + } + } + if (matchedFile) relevantFiles.push(display); + } + + const instructionPaths = context.instructionPaths + .filter((path) => scopes.some((scope) => + isPathInsideRoot(path, scope) || isPathInsideRoot(scope, dirname(path)))) + .map((path) => displayPath(path, context.root)); + const codeGraphDetected = existsSync(resolve(context.root, ".codegraph")); + + return buildBoundedPayload({ + startedAt, + maxCharacters, + build: (contentBudget) => { + const files = takeWithinCharacterBudget(relevantFiles, Math.floor(contentBudget * 0.18)); + const symbols = takeWithinCharacterBudget(relevantSymbols, Math.floor(contentBudget * 0.22)); + const matches = takeWithinCharacterBudget(searchMatches, Math.floor(contentBudget * 0.22)); + const instructions = takeWithinCharacterBudget(instructionPaths, Math.floor(contentBudget * 0.13)); + const impact = takeWithinCharacterBudget(relevantFiles, Math.floor(contentBudget * 0.1)); + const reads = takeWithinCharacterBudget(relevantFiles, Math.floor(contentBudget * 0.1)); + const truncated = scanTruncated + || files.truncated || symbols.truncated || matches.truncated + || instructions.truncated || impact.truncated || reads.truncated; + return { + payload: { + relevantFiles: files.items, + relevantSymbols: symbols.items, + searchMatches: matches.items, + applicableInstructions: instructions.items, + impactCandidates: impact.items, + recommendedReads: reads.items, + detectionMethod: codeGraphDetected + ? "codegraph_adapter_unavailable_fallback" as const + : "bounded_text_fallback" as const, + }, + returnedItems: files.items.length + symbols.items.length + matches.items.length, + truncated, + }; + }, + }); +} + +export interface ReviewChangesResult extends Record { + changedFiles: Array<{ path: string; status: string }>; + diffStat: string; + summary: string; + riskCandidates: string[]; + suspiciousChanges: Array<{ rule: string; file: string; line?: number }>; + testRecommendations: string[]; + truncated: boolean; + metrics: ToolMetrics; +} + +export async function reviewChanges( + context: WorkspaceInspectionContext, + options: { scope?: string; baseRef?: string; maxCharacters?: number } = {}, +): Promise { + const startedAt = performance.now(); + const maxCharacters = clampInteger(options.maxCharacters, 12_000, 2_000, 50_000); + const baseRef = options.baseRef?.trim() || "HEAD"; + validateBaseRef(baseRef); + await requireGitRef(context.root, baseRef); + const pathspec = options.scope ? await gitPathspec(context.root, options.scope) : undefined; + const suffix = pathspec ? ["--", pathspec] : ["--"]; + const [nameStatus, status, diffStat, diff, packageInfo] = await Promise.all([ + git(context.root, ["diff", "--no-ext-diff", "--no-textconv", "--name-status", "-z", baseRef, ...suffix]), + git(context.root, ["status", "--porcelain=v1", "-z", "--untracked-files=normal", ...suffix]), + git(context.root, ["diff", "--no-ext-diff", "--no-textconv", "--shortstat", baseRef, ...suffix]), + git(context.root, ["diff", "--no-ext-diff", "--no-textconv", "--unified=0", baseRef, ...suffix]), + readPackageSummary(context.root), + ]); + const changed = new Map(); + if (nameStatus.ok) { + for (const entry of parseNameStatus(nameStatus.stdout)) changed.set(entry.path, entry.status); + } + if (status.ok) { + for (const path of parseStatusPaths(status.stdout)) { + if (!changed.has(path)) changed.set(path, "untracked"); + } + } + const changedFiles = Array.from(changed, ([path, changeStatus]) => ({ path, status: changeStatus })) + .sort((a, b) => a.path.localeCompare(b.path)); + const suspicious = diff.ok ? inspectDiff(diff.stdout.slice(0, 300_000)) : []; + const riskCandidates = Array.from(new Set(suspicious.map((item) => item.rule))); + const testRecommendations = ["typecheck", "test", "build"] + .map((name) => recommendedCommand(packageInfo.scripts, name)) + .filter((command): command is string => command !== undefined); + + const result = buildBoundedPayload({ + startedAt, + maxCharacters, + build: (contentBudget) => { + const files = takeWithinCharacterBudget(changedFiles, Math.floor(contentBudget * 0.4)); + const risks = takeWithinCharacterBudget(riskCandidates, Math.floor(contentBudget * 0.15)); + const findings = takeWithinCharacterBudget(suspicious, Math.floor(contentBudget * 0.3)); + const tests = takeWithinCharacterBudget(testRecommendations, Math.floor(contentBudget * 0.1)); + const truncated = files.truncated || risks.truncated || findings.truncated || tests.truncated + || (diff.ok && diff.stdout.length > 300_000); + return { + payload: { + changedFiles: files.items, + diffStat: diffStat.ok ? diffStat.stdout.trim().slice(0, 500) : "", + summary: `${changedFiles.length} changed file(s); ${suspicious.length} suspicious pattern(s).`, + riskCandidates: risks.items, + suspiciousChanges: findings.items, + testRecommendations: tests.items, + truncated, + }, + returnedItems: files.items.length + findings.items.length, + truncated, + }; + }, + }); + + return { ...result, truncated: result.metrics.truncated }; +} + +async function readPackageSummary(root: string): Promise<{ + name?: string; + version?: string; + scripts: string[]; +}> { + const packagePath = resolve(root, "package.json"); + const realFile = await safeRealFile(packagePath, root); + if (!realFile || isSecretLikePath(realFile)) return { scripts: [] }; + try { + const info = await stat(realFile); + if (info.size > 512_000) return { scripts: [] }; + const parsed = JSON.parse(await readFile(realFile, "utf8")) as Record; + const scripts = parsed.scripts && typeof parsed.scripts === "object" && !Array.isArray(parsed.scripts) + ? Object.keys(parsed.scripts as Record) + .filter((name) => /^[A-Za-z0-9][A-Za-z0-9:._-]{0,99}$/.test(name)) + .slice(0, 100) + : []; + const packageName = typeof parsed.name === "string" && !containsSecretValue(parsed.name) + ? parsed.name.slice(0, 200) + : undefined; + const packageVersion = typeof parsed.version === "string" && !containsSecretValue(parsed.version) + ? parsed.version.slice(0, 50) + : undefined; + return { + ...(packageName ? { name: packageName } : {}), + ...(packageVersion ? { version: packageVersion } : {}), + scripts, + }; + } catch { + return { scripts: [] }; + } +} + +function recommendedCommand(scripts: string[], name: string): string | undefined { + if (!scripts.includes(name)) return undefined; + return name === "test" ? "npm test" : `npm run ${name}`; +} + +async function resolveScopes(root: string, paths: string[] | undefined): Promise { + if (!paths || paths.length === 0) return [await realpath(root)]; + const scopes: string[] = []; + for (const path of paths.slice(0, 25)) { + const resolved = resolveAllowedPath(path, root, [root]); + const real = await realpath(resolved); + const realRoot = await realpath(root); + if (!isPathInsideRoot(real, realRoot)) throw new Error(`Path is outside workspace root: ${path}`); + scopes.push(real); + } + return scopes; +} + +async function collectCandidateFiles( + root: string, + scopes: string[], + hardLimit: number, +): Promise<{ files: string[]; truncated: boolean }> { + const files: string[] = []; + let truncated = false; + const realRoot = await realpath(root); + + const visit = async (path: string): Promise => { + if (files.length >= hardLimit) { + truncated = true; + return; + } + let info; + try { + info = await stat(path); + } catch { + return; + } + if (info.isFile()) { + const realFile = await safeRealFile(path, realRoot); + if (!realFile || isSecretLikePath(realFile) || isGeneratedOrBinaryPath(realFile)) return; + files.push(realFile); + return; + } + if (!info.isDirectory()) return; + let realDirectory: string; + try { + realDirectory = await realpath(path); + } catch { + return; + } + if (!isPathInsideRoot(realDirectory, realRoot)) return; + let directory; + try { + directory = await opendir(path); + } catch { + return; + } + for await (const entry of directory) { + if (files.length >= hardLimit) { + truncated = true; + break; + } + if (entry.isDirectory() && SKIPPED_DIRS.has(entry.name)) continue; + await visit(resolve(path, entry.name)); + } + }; + + for (const scope of scopes) await visit(scope); + return { files, truncated }; +} + +async function readBoundedTextFile( + path: string, + root: string, + maxBytes: number, +): Promise { + const realFile = await safeRealFile(path, root); + if (!realFile || isSecretLikePath(realFile) || isGeneratedOrBinaryPath(realFile)) return undefined; + try { + const info = await stat(realFile); + if (info.size > maxBytes) return undefined; + const content = await readFile(realFile); + if (content.includes(0)) return undefined; + return content.toString("utf8"); + } catch { + return undefined; + } +} + +function searchableTokens(focus: string): string[] { + const normalized = focus.toLocaleLowerCase(); + const values = normalized.match(/[\p{L}\p{N}_-]{2,}/gu) ?? []; + return Array.from(new Set([normalized, ...values])).filter((value) => value.length >= 2).slice(0, 12); +} + +function filenameScore(path: string, focusTokens: string[]): number { + const name = basename(path).toLocaleLowerCase(); + return focusTokens.reduce((score, token) => score + (name.includes(token) ? 1 : 0), 0); +} + +function extractSymbol(line: string): string | undefined { + const match = line.match(/\b(?:class|interface|type|function|const|let|var|enum)\s+([A-Za-z_$][\w$]*)/); + return match?.[1]; +} + +function displayPath(path: string, root: string): string { + const lexicalPath = resolve(path); + const lexicalRoot = resolve(root); + let comparablePath = lexicalPath; + let comparableRoot = lexicalRoot; + if (!isPathInsideRoot(lexicalPath, lexicalRoot)) { + try { + comparablePath = realpathSync(lexicalPath); + comparableRoot = realpathSync(lexicalRoot); + } catch { + // Keep lexical paths when either side does not exist. + } + } + const relationship = relative(comparableRoot, comparablePath); + if (relationship === "" || relationship === ".") return "."; + if (relationship === ".." || relationship.startsWith(`..${sep}`)) return resolve(path).split(sep).join("/"); + return relationship.split(sep).join("/"); +} + +function parseStatusPaths(output: string): string[] { + const tokens = output.split("\0").filter(Boolean); + const paths: string[] = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] ?? ""; + if (token.length < 4) continue; + const status = token.slice(0, 2); + const path = token.slice(3); + if (path) paths.push(path); + if (status.includes("R") || status.includes("C")) index += 1; + } + return Array.from(new Set(paths)).sort(); +} + +function parseNameStatus(output: string): Array<{ path: string; status: string }> { + const tokens = output.split("\0").filter(Boolean); + const entries: Array<{ path: string; status: string }> = []; + for (let index = 0; index < tokens.length; index += 1) { + const status = tokens[index] ?? ""; + const path = tokens[index + 1]; + if (!path) break; + if (status.startsWith("R") || status.startsWith("C")) { + const destination = tokens[index + 2]; + if (destination) entries.push({ path: destination, status }); + index += 2; + } else { + entries.push({ path, status }); + index += 1; + } + } + return entries; +} + +function inspectDiff(diff: string): Array<{ rule: string; file: string; line?: number }> { + const findings: Array<{ rule: string; file: string; line?: number }> = []; + let file = "unknown"; + let nextLine: number | undefined; + for (const line of diff.split("\n")) { + if (line.startsWith("+++ b/")) { + file = line.slice(6); + nextLine = undefined; + continue; + } + const hunk = line.match(/^@@[^+]*\+(\d+)/); + if (hunk) { + nextLine = Number(hunk[1]); + continue; + } + if (!line.startsWith("+") || line.startsWith("+++")) continue; + const value = line.slice(1); + const add = (rule: string) => { + if (!findings.some((item) => item.rule === rule && item.file === file && item.line === nextLine)) { + findings.push({ rule, file, ...(nextLine === undefined ? {} : { line: nextLine }) }); + } + }; + if (containsSecretValue(value)) add("potential_secret_value"); + if (/\b(?:eval|exec)\s*\(/.test(value)) add("dynamic_code_execution"); + if (/chmod\s+777|dangerouslySetInnerHTML/.test(value)) add("dangerous_permission_or_html"); + if (/\bas\s+any\b|:\s*any\b/.test(value)) add("type_safety_escape"); + if (/oauth|permissions?|allowed[_-]?roots?|iam/i.test(file)) add("permission_boundary_changed"); + if (/package(?:-lock)?\.json$|pnpm-lock|yarn\.lock$/.test(file)) add("dependency_manifest_changed"); + if (nextLine !== undefined) nextLine += 1; + if (findings.length >= 100) break; + } + return findings; +} + +function validateBaseRef(baseRef: string): void { + if (!baseRef || baseRef.length > 200 || baseRef.startsWith("-") || /[\0-\x1f\x7f]/.test(baseRef)) { + throw new Error(`Invalid baseRef: ${baseRef}`); + } +} + +async function requireGitRef(root: string, baseRef: string): Promise { + const result = await git(root, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${baseRef}^{commit}`]); + if (!result.ok) throw new Error(`Unknown baseRef: ${baseRef}`); +} + +async function gitPathspec(root: string, scope: string): Promise { + const absolute = resolveAllowedPath(scope, root, [root]); + const realRoot = await realpath(root); + let realScope: string; + try { + realScope = await realpath(absolute); + } catch { + realScope = absolute; + } + if (!isPathInsideRoot(realScope, realRoot)) throw new Error(`Path is outside workspace root: ${scope}`); + const relationship = relative(realRoot, realScope).split(sep).join("/"); + return `:(literal)${relationship || "."}`; +} + +async function git(root: string, args: string[]): Promise<{ ok: boolean; stdout: string }> { + try { + const result = await execFileAsync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 5_000, + maxBuffer: MAX_GIT_BUFFER, + windowsHide: true, + }); + return { ok: true, stdout: result.stdout }; + } catch (error) { + const stdout = typeof error === "object" && error && "stdout" in error && typeof error.stdout === "string" + ? error.stdout + : ""; + return { ok: false, stdout }; + } +} diff --git a/src/config.test.ts b/src/config.test.ts index 0b4f99a8..c0ad2c4b 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -12,7 +12,36 @@ const baseEnv = { DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }; -assert.equal(loadConfig(baseEnv).widgets, "full"); +assert.equal(loadConfig(baseEnv).openWorkspacePayload, "compact"); +assert.equal(loadConfig(baseEnv).openWorkspaceInstructionChars, 6_000); +assert.equal(loadConfig(baseEnv).usageContent, "compact"); +assert.equal(loadConfig(baseEnv).skillMatcher, false); +assert.equal(loadConfig(baseEnv).compoundTools, false); +assert.equal(loadConfig(baseEnv).builtinProfiles, false); +assert.equal(loadConfig(baseEnv).designAudit, false); +assert.deepEqual(loadConfig(baseEnv).designAuditAllowedHosts, ["localhost", "127.0.0.1", "::1"]); +assert.equal(loadConfig(baseEnv).widgets, "off"); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_PAYLOAD: "full" }).widgets, + "full", +); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_PAYLOAD: "full" }).openWorkspacePayload, + "full", +); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS: "8000" }) + .openWorkspaceInstructionChars, + 8_000, +); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_USAGE_CONTENT: "off" }).usageContent, + "off", +); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_USAGE_CONTENT: "full" }).usageContent, + "full", +); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); @@ -60,6 +89,35 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), /Invalid DEVSPACE_TOOL_MODE: invalid/, ); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_PAYLOAD: "invalid" }), + /Invalid DEVSPACE_OPEN_WORKSPACE_PAYLOAD: invalid/, +); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_USAGE_CONTENT: "invalid" }), + /Invalid DEVSPACE_USAGE_CONTENT: invalid/, +); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS: "0" }), + /Invalid DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS: 0/, +); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS: "255" }), + /Invalid DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS: 255/, +); +for (const name of [ + "DEVSPACE_SKILL_MATCHER", + "DEVSPACE_COMPOUND_TOOLS", + "DEVSPACE_BUILTIN_PROFILES", + "DEVSPACE_DESIGN_AUDIT", +]) { + assert.equal(loadConfig({ ...baseEnv, [name]: "true" })[featureKey(name)], true); + assert.equal(loadConfig({ ...baseEnv, [name]: "off" })[featureKey(name)], false); + assert.throws( + () => loadConfig({ ...baseEnv, [name]: "invalid" }), + new RegExp(`Invalid ${name}: invalid`), + ); +} assert.deepEqual(loadConfig(baseEnv).logging, { level: "info", @@ -71,6 +129,16 @@ assert.deepEqual(loadConfig(baseEnv).logging, { trustProxy: false, }); +function featureKey(name: string): "skillMatcher" | "compoundTools" | "builtinProfiles" | "designAudit" { + switch (name) { + case "DEVSPACE_SKILL_MATCHER": return "skillMatcher"; + case "DEVSPACE_COMPOUND_TOOLS": return "compoundTools"; + case "DEVSPACE_BUILTIN_PROFILES": return "builtinProfiles"; + case "DEVSPACE_DESIGN_AUDIT": return "designAudit"; + default: throw new Error(`Unknown feature flag: ${name}`); + } +} + assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "silent" }).logging.level, "silent"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "error" }).logging.level, "error"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_LOG_LEVEL: "warn" }).logging.level, "warn"); diff --git a/src/config.ts b/src/config.ts index 4fc1bcbb..019637c4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,8 @@ import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user- export type ToolMode = "minimal" | "full" | "codex"; export type WidgetMode = "off" | "changes" | "full"; +export type OpenWorkspacePayloadMode = "compact" | "full"; +export type UsageContentMode = "off" | "compact" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; @@ -19,6 +21,14 @@ export interface ServerConfig { publicBaseUrl: string; toolMode: ToolMode; widgets: WidgetMode; + openWorkspacePayload: OpenWorkspacePayloadMode; + openWorkspaceInstructionChars: number; + usageContent: UsageContentMode; + skillMatcher: boolean; + compoundTools: boolean; + builtinProfiles: boolean; + designAudit: boolean; + designAuditAllowedHosts: string[]; stateDir: string; worktreeRoot: string; skillsEnabled: boolean; @@ -81,6 +91,14 @@ function parseBoolean(value: string | undefined): boolean { return ["1", "true", "yes", "on"].includes(value?.toLowerCase() ?? ""); } +function parseFeatureFlag(value: string | undefined, name: string): boolean { + if (value === undefined) return false; + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + throw new Error(`Invalid ${name}: ${value}`); +} + function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { const mode = env.DEVSPACE_TOOL_MODE; if (mode === "minimal" || mode === "full" || mode === "codex") return mode; @@ -135,6 +153,19 @@ function parsePositiveInteger(value: string | undefined, fallback: number, name: return parsed; } +function parseIntegerAtLeast( + value: string | undefined, + fallback: number, + name: string, + minimum: number, +): number { + const parsed = parsePositiveInteger(value, fallback, name); + if (parsed < minimum) { + throw new Error(`Invalid ${name}: ${value}`); + } + return parsed; +} + function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { return { level: parseLogLevel(env.DEVSPACE_LOG_LEVEL), @@ -147,8 +178,23 @@ function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { }; } -function parseWidgetMode(value: string | undefined): WidgetMode { - if (!value || value === "full") return "full"; +function parseOpenWorkspacePayloadMode(value: string | undefined): OpenWorkspacePayloadMode { + if (!value || value === "compact") return "compact"; + if (value === "full") return value; + + throw new Error(`Invalid DEVSPACE_OPEN_WORKSPACE_PAYLOAD: ${value}`); +} + +function parseUsageContentMode(value: string | undefined): UsageContentMode { + if (!value || value === "compact") return "compact"; + if (value === "off" || value === "full") return value; + + throw new Error(`Invalid DEVSPACE_USAGE_CONTENT: ${value}`); +} + +function parseWidgetMode(value: string | undefined, fallback: WidgetMode): WidgetMode { + if (!value) return fallback; + if (value === "full") return "full"; if (value === "off" || value === "changes") return value; throw new Error(`Invalid DEVSPACE_WIDGETS: ${value}`); @@ -206,6 +252,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const publicBaseUrl = parsePublicBaseUrl( env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), ); + const openWorkspacePayload = parseOpenWorkspacePayloadMode(env.DEVSPACE_OPEN_WORKSPACE_PAYLOAD); const derivedAllowedHosts = [ "localhost", "127.0.0.1", @@ -223,7 +270,23 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, toolMode: parseToolMode(env), - widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), + widgets: parseWidgetMode(env.DEVSPACE_WIDGETS, openWorkspacePayload === "compact" ? "off" : "full"), + openWorkspacePayload, + openWorkspaceInstructionChars: parseIntegerAtLeast( + env.DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS, + 6_000, + "DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS", + 256, + ), + usageContent: parseUsageContentMode(env.DEVSPACE_USAGE_CONTENT), + skillMatcher: parseFeatureFlag(env.DEVSPACE_SKILL_MATCHER, "DEVSPACE_SKILL_MATCHER"), + compoundTools: parseFeatureFlag(env.DEVSPACE_COMPOUND_TOOLS, "DEVSPACE_COMPOUND_TOOLS"), + builtinProfiles: parseFeatureFlag(env.DEVSPACE_BUILTIN_PROFILES, "DEVSPACE_BUILTIN_PROFILES"), + designAudit: parseFeatureFlag(env.DEVSPACE_DESIGN_AUDIT, "DEVSPACE_DESIGN_AUDIT"), + designAuditAllowedHosts: parseStringList( + env.DEVSPACE_DESIGN_AUDIT_ALLOWED_HOSTS, + ["localhost", "127.0.0.1", "::1"], + ), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), diff --git a/src/design-audit.test.ts b/src/design-audit.test.ts new file mode 100644 index 00000000..479048e5 --- /dev/null +++ b/src/design-audit.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "./config.js"; +import { + runDesignAudit, + validateDesignAuditUrl, + type DesignAuditAdapter, +} from "./design-audit.js"; + +const root = await mkdtemp(join(tmpdir(), "gpt-agent-design-audit-test-")); +const baseEnv = { + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", +}; + +try { + const disabled = await runDesignAudit(loadConfig(baseEnv), { + workspaceRoot: root, + url: "not-a-url", + }); + assert.equal(disabled.status, "disabled"); + assert.equal(disabled.metrics.payloadCharacters, JSON.stringify(disabled).length); + + const enabledConfig = loadConfig({ ...baseEnv, DEVSPACE_DESIGN_AUDIT: "1" }); + const unavailable = await runDesignAudit(enabledConfig, { + workspaceRoot: root, + url: "http://localhost:3000/path?token=hidden#fragment", + }); + assert.equal(unavailable.status, "unavailable"); + assert.equal(unavailable.validatedUrl, "http://localhost:3000/path"); + assert.equal(JSON.stringify(unavailable).includes("hidden"), false); + assert.equal(unavailable.metrics.payloadCharacters, JSON.stringify(unavailable).length); + + await assert.rejects( + () => validateDesignAuditUrl("file:///tmp/index.html", ["localhost"]), + /http or https/, + ); + await assert.rejects( + () => validateDesignAuditUrl("http://user:pass@localhost:3000", ["localhost"]), + /must not contain credentials/, + ); + await assert.rejects( + () => validateDesignAuditUrl("http://192.168.1.2", ["localhost"]), + /not allowed/, + ); + await assert.rejects( + () => validateDesignAuditUrl("http://[fe90::1]", ["fe90::1"]), + /private or unsafe/, + ); + await assert.rejects( + () => validateDesignAuditUrl("http://[::ffff:7f00:1]", ["::ffff:7f00:1"]), + /unsafe mapped address/, + ); + await assert.rejects( + () => validateDesignAuditUrl("http://198.18.0.1", ["198.18.0.1"]), + /private or unsafe/, + ); + await assert.rejects( + () => validateDesignAuditUrl("http://224.0.0.1", ["224.0.0.1"]), + /private or unsafe/, + ); + await assert.rejects( + () => runDesignAudit(enabledConfig, { + workspaceRoot: root, + url: "http://localhost:3000", + outputDirectory: "../escape", + }), + /must stay inside/, + ); + await assert.rejects( + () => runDesignAudit(enabledConfig, { + workspaceRoot: root, + url: "http://localhost:3000", + routes: ["//example.com/private"], + }), + /must stay on the validated origin/, + ); + + const outputDirectory = join(root, "audit-output"); + const artifactPath = join(outputDirectory, "desktop.png"); + await mkdir(outputDirectory); + await writeFile(artifactPath, "image fixture"); + const adapter: DesignAuditAdapter = { + name: "fake-adapter", + async availability() { return { available: true }; }, + async run() { + return { + artifacts: [ + ...Array.from({ length: 55 }, () => ({ + kind: "desktop-screenshot" as const, + path: artifactPath, + })), + { kind: "report" as const, path: join(root, "outside.txt") }, + ], + consoleErrors: 2, + overflowIssues: 1, + accessibilityIssues: 3, + headingIssues: 1, + diagnostics: Array.from( + { length: 55 }, + () => "api_key=very-secret-value-that-must-not-leak", + ), + }; + }, + }; + const completed = await runDesignAudit(enabledConfig, { + workspaceRoot: root, + url: "http://localhost:3000", + outputDirectory: "audit-output", + }, adapter); + assert.equal(completed.status, "completed"); + assert.equal(completed.metrics.truncated, true); + assert.ok(completed.metrics.payloadCharacters <= 12_000); + assert.equal(completed.metrics.payloadCharacters, JSON.stringify(completed).length); + assert.equal(JSON.stringify(completed).includes("very-secret-value"), false); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/design-audit.ts b/src/design-audit.ts new file mode 100644 index 00000000..df798dc2 --- /dev/null +++ b/src/design-audit.ts @@ -0,0 +1,338 @@ +import { lookup } from "node:dns/promises"; +import { realpath } from "node:fs/promises"; +import { isIP } from "node:net"; +import { dirname, resolve } from "node:path"; +import type { ServerConfig } from "./config.js"; +import { isPathInsideRoot } from "./roots.js"; +import { redactSensitiveText, safeRealFile } from "./safe-inspection.js"; +import { + buildBoundedPayload, + measuredPayload, + takeWithinCharacterBudget, + type ToolMetrics, +} from "./tool-metrics.js"; + +export interface DesignAuditInput { + workspaceRoot: string; + url: string; + desktopViewport?: { width: number; height: number }; + mobileViewport?: { width: number; height: number }; + routes?: string[]; + checks?: string[]; + outputDirectory?: string; +} + +export interface DesignAuditArtifact { + kind: "desktop-screenshot" | "mobile-screenshot" | "report"; + path: string; +} + +export interface DesignAuditAdapterResult { + artifacts: DesignAuditArtifact[]; + consoleErrors: number; + overflowIssues: number; + accessibilityIssues: number; + headingIssues: number; + diagnostics: string[]; +} + +export interface DesignAuditAdapter { + readonly name: string; + availability(): Promise<{ available: boolean; diagnostic?: string }>; + run(input: DesignAuditInput & { + validatedUrl: URL; + outputDirectory: string; + validateNavigationUrl(value: string): Promise; + }): Promise; +} + +export interface DesignAuditResult extends Record { + status: "disabled" | "unavailable" | "completed"; + adapter: string; + validatedUrl?: string; + artifacts: DesignAuditArtifact[]; + diagnostics: string[]; + consoleErrors?: number; + overflowIssues?: number; + accessibilityIssues?: number; + headingIssues?: number; + metrics: ToolMetrics; +} + +export class UnavailableDesignAuditAdapter implements DesignAuditAdapter { + readonly name = "unavailable"; + + async availability(): Promise<{ available: false; diagnostic: string }> { + return { + available: false, + diagnostic: "No Playwright, Chrome DevTools, Browser MCP runtime bridge, or accessibility engine is available.", + }; + } + + async run(): Promise { + throw new Error("Design audit adapter is unavailable."); + } +} + +export async function runDesignAudit( + config: ServerConfig, + input: DesignAuditInput, + adapter: DesignAuditAdapter = new UnavailableDesignAuditAdapter(), +): Promise { + const startedAt = performance.now(); + if (!config.designAudit) { + return measuredPayload({ + status: "disabled" as const, + adapter: safeAdapterName(adapter.name), + artifacts: [], + diagnostics: ["Design audit is disabled. Set DEVSPACE_DESIGN_AUDIT=1 to expose the adapter."], + }, { + startedAt, + returnedItems: 0, + truncated: false, + }); + } + + const validatedUrl = await validateDesignAuditUrl(input.url, config.designAuditAllowedHosts); + const validatedRoutes = validateRoutes(validatedUrl, input.routes); + const outputDirectory = await validateOutputDirectory(config, input.workspaceRoot, input.outputDirectory); + const availability = await adapter.availability(); + if (!availability.available) { + return measuredPayload({ + status: "unavailable" as const, + adapter: safeAdapterName(adapter.name), + validatedUrl: safeUrlForOutput(validatedUrl), + artifacts: [], + diagnostics: [safeDiagnostic(availability.diagnostic ?? "Design audit adapter is unavailable.")], + }, { + startedAt, + returnedItems: 0, + truncated: false, + }); + } + + const result = await adapter.run({ + ...input, + routes: validatedRoutes, + validatedUrl, + outputDirectory, + validateNavigationUrl: (value) => validateDesignAuditUrl(value, config.designAuditAllowedHosts), + }); + const artifacts: DesignAuditArtifact[] = []; + let invalidArtifacts = 0; + for (const artifact of result.artifacts.slice(0, 50)) { + const candidate = resolve(outputDirectory, artifact.path); + const realFile = await safeRealFile(candidate, outputDirectory); + if (!realFile) { + invalidArtifacts += 1; + continue; + } + artifacts.push({ kind: artifact.kind, path: realFile }); + } + const diagnostics = [ + ...result.diagnostics.slice(0, 50).map(safeDiagnostic), + ...(invalidArtifacts > 0 ? [`Ignored ${invalidArtifacts} artifact path(s) outside the audit output directory.`] : []), + ]; + const counts = { + consoleErrors: safeCount(result.consoleErrors), + overflowIssues: safeCount(result.overflowIssues), + accessibilityIssues: safeCount(result.accessibilityIssues), + headingIssues: safeCount(result.headingIssues), + }; + + return buildBoundedPayload({ + startedAt, + maxCharacters: 12_000, + build: (contentBudget) => { + const boundedArtifacts = takeWithinCharacterBudget(artifacts, Math.floor(contentBudget * 0.55)); + const boundedDiagnostics = takeWithinCharacterBudget(diagnostics, Math.floor(contentBudget * 0.35)); + const truncated = result.artifacts.length > 50 + || result.diagnostics.length > 50 + || invalidArtifacts > 0 + || boundedArtifacts.truncated + || boundedDiagnostics.truncated; + return { + payload: { + status: "completed" as const, + adapter: safeAdapterName(adapter.name), + validatedUrl: safeUrlForOutput(validatedUrl), + artifacts: boundedArtifacts.items, + diagnostics: boundedDiagnostics.items, + ...counts, + }, + returnedItems: boundedArtifacts.items.length + + counts.consoleErrors + + counts.overflowIssues + + counts.accessibilityIssues + + counts.headingIssues, + truncated, + }; + }, + }); +} + +export async function validateDesignAuditUrl( + value: string, + allowedHosts: string[], +): Promise { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Design audit URL must be an absolute HTTP(S) URL."); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Design audit URL must use http or https."); + } + if (url.username || url.password) { + throw new Error("Design audit URL must not contain credentials."); + } + + const hostname = normalizeHostname(url.hostname); + const allowed = allowedHosts.some((entry) => { + const normalized = entry.trim().toLowerCase(); + if (!normalized) return false; + if (normalized.includes("://")) { + try { + return new URL(normalized).origin === url.origin; + } catch { + return false; + } + } + return normalizeHostname(normalized) === hostname; + }); + if (!allowed) throw new Error(`Design audit URL host is not allowed: ${hostname}`); + if (hostname === "0.0.0.0" || hostname === "metadata.google.internal") { + throw new Error(`Design audit URL host is unsafe: ${hostname}`); + } + if (hostname.startsWith("::ffff:")) { + throw new Error(`Design audit URL host uses an unsafe mapped address: ${hostname}`); + } + + const addresses = isIP(hostname) + ? [{ address: hostname }] + : await lookup(hostname, { all: true, verbatim: true }); + const loopbackHost = hostname === "localhost" || isLoopbackAddress(hostname); + const unsafeResolution = addresses.length === 0 || addresses.some(({ address }) => + loopbackHost ? !isLoopbackAddress(address) : isPrivateOrMetadataAddress(address)); + if (unsafeResolution) { + throw new Error(`Design audit URL resolves to a private or unsafe address: ${hostname}`); + } + return url; +} + +async function validateOutputDirectory( + config: ServerConfig, + workspaceRoot: string, + value: string | undefined, +): Promise { + if (!value) return resolve(config.stateDir, "design-audits"); + const directory = resolve(workspaceRoot, value); + if (!isPathInsideRoot(directory, workspaceRoot)) { + throw new Error("Design audit outputDirectory must stay inside the workspace root."); + } + const realWorkspaceRoot = await realpath(workspaceRoot); + let existing = directory; + for (;;) { + try { + const realExisting = await realpath(existing); + if (!isPathInsideRoot(realExisting, realWorkspaceRoot)) { + throw new Error("Design audit outputDirectory resolves outside the workspace root."); + } + break; + } catch (error) { + if (error instanceof Error && error.message.includes("resolves outside")) throw error; + const parent = dirname(existing); + if (parent === existing) throw new Error("Unable to validate design audit outputDirectory."); + existing = parent; + } + } + return directory; +} + +function validateRoutes(baseUrl: URL, routes: string[] | undefined): string[] | undefined { + if (!routes) return undefined; + return routes.slice(0, 20).map((route) => { + let resolved: URL; + try { + resolved = new URL(route, baseUrl); + } catch { + throw new Error(`Invalid design audit route: ${route}`); + } + if (resolved.origin !== baseUrl.origin || resolved.username || resolved.password) { + throw new Error(`Design audit route must stay on the validated origin: ${route}`); + } + return `${resolved.pathname}${resolved.search}`; + }); +} + +function normalizeHostname(hostname: string): string { + return hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); +} + +function isPrivateOrMetadataAddress(address: string): boolean { + const normalized = normalizeHostname(address); + const mapped = mappedIpv4(normalized); + const ipv4 = mapped ?? normalized; + const parts = ipv4.split(".").map(Number); + if (parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) { + const [a, b, c] = parts as [number, number, number, number]; + return a === 0 + || a === 10 + || a === 127 + || a >= 224 + || (a === 100 && b >= 64 && b <= 127) + || (a === 169 && b === 254) + || (a === 172 && b >= 16 && b <= 31) + || (a === 192 && b === 0 && c === 0) + || (a === 192 && b === 0 && c === 2) + || (a === 192 && b === 88 && c === 99) + || (a === 192 && b === 168) + || (a === 198 && (b === 18 || b === 19)) + || (a === 198 && b === 51 && c === 100) + || (a === 203 && b === 0 && c === 113); + } + + if (isIP(normalized) === 6) { + if (normalized === "::" || normalized === "::1") return true; + if (/^fe[89a-f]/.test(normalized) || /^(?:fc|fd|ff)/.test(normalized)) return true; + if (normalized.startsWith("2001:db8:")) return true; + return !/^[23][0-9a-f]{0,3}:/.test(normalized); + } + + return true; +} + +function isLoopbackAddress(address: string): boolean { + const normalized = normalizeHostname(address); + if (normalized === "::1") return true; + const ipv4 = mappedIpv4(normalized) ?? normalized; + if (!/^\d+\.\d+\.\d+\.\d+$/.test(ipv4)) return false; + return Number(ipv4.split(".")[0]) === 127; +} + +function mappedIpv4(address: string): string | undefined { + const dotted = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1]; + if (dotted) return dotted; + const hexadecimal = address.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + if (!hexadecimal) return undefined; + const high = Number.parseInt(hexadecimal[1]!, 16); + const low = Number.parseInt(hexadecimal[2]!, 16); + return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`; +} + +function safeAdapterName(name: string): string { + return redactSensitiveText(String(name)).slice(0, 100); +} + +function safeDiagnostic(value: string): string { + return redactSensitiveText(String(value)).replace(/\s+/g, " ").trim().slice(0, 500); +} + +function safeCount(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.min(1_000_000, Math.floor(value))) : 0; +} + +function safeUrlForOutput(url: URL): string { + return `${url.origin}${url.pathname}`; +} diff --git a/src/local-agent-profiles.test.ts b/src/local-agent-profiles.test.ts index 7665b9f8..1e77ddf7 100644 --- a/src/local-agent-profiles.test.ts +++ b/src/local-agent-profiles.test.ts @@ -3,7 +3,11 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "./config.js"; -import { loadLocalAgentProfiles, summarizeLocalAgentProfile } from "./local-agent-profiles.js"; +import { + assertLocalAgentProfileBoundary, + loadLocalAgentProfiles, + summarizeLocalAgentProfile, +} from "./local-agent-profiles.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agent-profiles-test-")); @@ -71,6 +75,7 @@ try { assert.equal(profiles[0]?.provider, "claude"); assert.equal(profiles[0]?.model, "sonnet"); assert.equal(profiles[0]?.thinking, "high"); + assert.equal(profiles[0]?.writeMode, "allowed"); assert.equal(profiles[0]?.body, "Project body."); assert.deepEqual(summarizeLocalAgentProfile(profiles[0]!), { name: "reviewer", @@ -78,8 +83,54 @@ try { provider: "claude", model: "sonnet", thinking: "high", + writeMode: "allowed", }); + await writeFile( + join(workspaceRoot, ".devspace", "agents", "readonly.md"), + [ + "---", + "name: readonly", + "description: Read-only reviewer.", + "provider: codex", + "write-mode: read_only", + "---", + "", + "Review only.", + ].join("\n"), + ); + const withReadOnly = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); + assert.equal(withReadOnly.find((profile) => profile.name === "readonly")?.writeMode, "read_only"); + assert.doesNotThrow(() => assertLocalAgentProfileBoundary( + withReadOnly.find((profile) => profile.name === "readonly")!, + )); + assert.throws( + () => assertLocalAgentProfileBoundary({ + ...withReadOnly.find((profile) => profile.name === "readonly")!, + provider: "claude", + }), + /requires the codex provider/, + ); + assert.throws( + () => assertLocalAgentProfileBoundary({ + ...withReadOnly.find((profile) => profile.name === "readonly")!, + name: "codex", + }), + /reserved for the raw provider target/, + ); + + await writeFile( + join(workspaceRoot, ".devspace", "agents", "invalid-mode.md"), + [ + "---", + "name: invalid-mode", + "description: Invalid mode.", + "provider: codex", + "write-mode: full_access", + "---", + ].join("\n"), + ); + await writeFile( join(workspaceRoot, ".devspace", "agents", "custom.md"), [ @@ -94,7 +145,21 @@ try { ].join("\n"), ); const profilesWithInvalid = await loadLocalAgentProfiles(enabledConfig, workspaceRoot); - assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["reviewer"]); + assert.deepEqual(profilesWithInvalid.map((profile) => profile.name), ["readonly", "reviewer"]); + + const builtinConfig = loadConfig({ + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: workspaceRoot, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_BUILTIN_PROFILES: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }); + const builtinProfiles = await loadLocalAgentProfiles(builtinConfig, workspaceRoot); + for (const name of ["design", "explore", "implement", "review"]) { + assert.ok(builtinProfiles.some((profile) => profile.name === name)); + } + assert.equal(builtinProfiles.find((profile) => profile.name === "explore")?.writeMode, "read_only"); + assert.equal(builtinProfiles.find((profile) => profile.name === "implement")?.writeMode, "allowed"); const disabledConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir, diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index a7fa0d87..2d37d086 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -1,8 +1,10 @@ import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; import type { ServerConfig } from "./config.js"; +import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot"; @@ -21,6 +23,7 @@ export interface LocalAgentProfile { provider: LocalAgentProvider; model?: string; thinking?: string; + writeMode: Exclude; filePath: string; body: string; disabled: boolean; @@ -32,6 +35,7 @@ export interface LocalAgentProfileSummary { provider: LocalAgentProvider; model?: string; thinking?: string; + writeMode: Exclude; } interface ParsedFrontmatter { @@ -49,9 +53,10 @@ export async function loadLocalAgentProfiles( if (!config.subagents) return []; const profileDirs = [ + config.builtinProfiles ? builtinProfilesDir() : undefined, config.devspaceAgentsDir, join(workspaceRoot, ".devspace", "agents"), - ]; + ].filter((directory): directory is string => directory !== undefined); const profilesByName = new Map(); for (const directory of profileDirs) { @@ -74,6 +79,7 @@ export function summarizeLocalAgentProfile( provider: profile.provider, model: profile.model, thinking: profile.thinking, + writeMode: profile.writeMode, }; } @@ -158,12 +164,27 @@ function profileFromFrontmatter( provider, model: readString(frontmatter, "model"), thinking: readString(frontmatter, "thinking"), + writeMode: readWriteMode(frontmatter, filePath), filePath, body, disabled: frontmatter.disabled === true, }; } +function builtinProfilesDir(): string { + return fileURLToPath(new URL("../agents", import.meta.url)); +} + +function readWriteMode( + frontmatter: Record, + filePath: string, +): Exclude { + const value = readString(frontmatter, "write-mode") ?? readString(frontmatter, "writeMode"); + if (!value || value === "allowed") return "allowed"; + if (value === "read_only") return value; + throw new Error(`Subagent profile write-mode must be read_only or allowed: ${filePath}`); +} + function readProvider(frontmatter: Record, filePath: string): LocalAgentProvider { const provider = readString(frontmatter, "provider"); if (!provider) { @@ -181,6 +202,19 @@ export function isLocalAgentProvider(value: string): value is LocalAgentProvider return PROVIDERS.has(value as LocalAgentProvider); } +export function assertLocalAgentProfileBoundary(profile: LocalAgentProfile): void { + if (profile.writeMode === "read_only" && profile.provider !== "codex") { + throw new Error( + `Read-only profile ${profile.name} requires the codex provider; ${profile.provider} cannot enforce this boundary.`, + ); + } + if (profile.writeMode === "read_only" && profile.name === profile.provider) { + throw new Error( + `Read-only profile name ${profile.name} is reserved for the raw provider target.`, + ); + } +} + function readString(frontmatter: Record, key: string): string | undefined { const value = frontmatter[key]; if (typeof value !== "string") return undefined; diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 1d45d166..5483f59f 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -56,6 +56,8 @@ assert.deepEqual(codex.started[0], { approvalPolicy: "never", model: undefined, modelReasoningEffort: undefined, + networkAccessEnabled: false, + webSearchMode: "disabled", }); await runtime.run({ @@ -72,6 +74,8 @@ assert.deepEqual(codex.started[1], { approvalPolicy: "never", model: "gpt-5.4", modelReasoningEffort: "high", + networkAccessEnabled: undefined, + webSearchMode: undefined, }); const resumed = await runtime.run({ @@ -92,6 +96,8 @@ assert.deepEqual(codex.resumed, [ approvalPolicy: "never", model: undefined, modelReasoningEffort: undefined, + networkAccessEnabled: undefined, + webSearchMode: undefined, }, }, ]); diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 54130c2e..a9928f84 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -55,12 +55,15 @@ function sandboxModeFor(writeMode: LocalAgentWriteMode | undefined): SandboxMode } function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { + const readOnly = input.writeMode === "read_only" || input.writeMode === undefined; return { workingDirectory: input.workspace, sandboxMode: sandboxModeFor(input.writeMode), approvalPolicy: "never", model: input.model, modelReasoningEffort: input.thinking as ModelReasoningEffort | undefined, + networkAccessEnabled: readOnly ? false : undefined, + webSearchMode: readOnly ? "disabled" : undefined, }; } diff --git a/src/local-agent-targets.test.ts b/src/local-agent-targets.test.ts index 3f1ae08f..dcb45c02 100644 --- a/src/local-agent-targets.test.ts +++ b/src/local-agent-targets.test.ts @@ -13,6 +13,7 @@ const profiles: LocalAgentProfile[] = [ provider: "codex", model: "gpt-5-codex", thinking: "high", + writeMode: "read_only", filePath: "/workspace/.devspace/agents/reviewer.md", body: "Review carefully.", disabled: false, @@ -22,6 +23,7 @@ const profiles: LocalAgentProfile[] = [ description: "A profile that shadows the raw provider.", provider: "opencode", model: "qwen/custom", + writeMode: "allowed", filePath: "/workspace/.devspace/agents/claude.md", body: "Use OpenCode.", disabled: false, diff --git a/src/pi-tools.test.ts b/src/pi-tools.test.ts new file mode 100644 index 00000000..55f1a857 --- /dev/null +++ b/src/pi-tools.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runShellTool } from "./pi-tools.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-approved-shell-test-")); +const commandsFile = join(root, "approved.json"); +const previousCommandsFile = process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE; + +try { + await mkdir(join(root, "nested")); + process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE = commandsFile; + await writeFile(commandsFile, JSON.stringify({ + commands: [{ + alias: "where", + workspaceRoot: root, + workingDirectory: "nested", + command: "pwd", + }], + })); + + const allowed = await runShellTool( + { command: "devspace-approved where" }, + { cwd: root, root }, + ); + assert.equal(allowed.isError, undefined); + assert.match(allowed.content[0]?.type === "text" ? allowed.content[0].text : "", /nested/); + + await writeFile(commandsFile, JSON.stringify({ + commands: [{ + alias: "escape", + workspaceRoot: root, + workingDirectory: "..", + command: "pwd", + }], + })); + const denied = await runShellTool( + { command: "devspace-approved escape" }, + { cwd: root, root }, + ); + assert.equal(denied.isError, true); + assert.match(denied.content[0]?.type === "text" ? denied.content[0].text : "", /outside allowed roots/); +} finally { + if (previousCommandsFile === undefined) { + delete process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE; + } else { + process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE = previousCommandsFile; + } + await rm(root, { recursive: true, force: true }); +} diff --git a/src/pi-tools.ts b/src/pi-tools.ts index 238b9c54..dd72dd72 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -1,3 +1,6 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; import { createBashTool, createEditTool, @@ -16,7 +19,7 @@ import { type WriteToolInput, type AgentToolResult, } from "@earendil-works/pi-coding-agent"; -import { resolveAllowedPath } from "./roots.js"; +import { expandHomePath, resolveAllowedPath } from "./roots.js"; type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; export type ToolResponse = { @@ -50,6 +53,84 @@ function formatToolError(error: unknown): McpContent[] { return [{ type: "text", text: message }]; } +interface ApprovedShellCommand { + alias: string; + enabled?: boolean; + workspaceRoot: string; + workingDirectory?: string; + command: string; +} + +const approvedCommandPrefix = "devspace-approved "; +function approvedCommandsPath(): string { + return resolve(expandHomePath( + process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE + ?? join(homedir(), ".devspace", "approved-shell-commands.json"), + )); +} + +async function loadApprovedShellCommands(): Promise { + try { + const raw = await readFile(approvedCommandsPath(), "utf8"); + const parsed = JSON.parse(raw) as { commands?: ApprovedShellCommand[] }; + return Array.isArray(parsed.commands) ? parsed.commands : []; + } catch { + return []; + } +} + +async function resolveApprovedShellAlias( + input: BashToolInput, + context: ToolContext, +): Promise<{ input: BashToolInput; cwd: string }> { + const rawCommand = String(input.command ?? "").trim(); + if (!rawCommand.startsWith(approvedCommandPrefix)) { + return { input, cwd: context.cwd }; + } + + const alias = rawCommand.slice(approvedCommandPrefix.length).trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(alias)) { + throw new Error("Invalid approved shell command alias."); + } + + const commands = await loadApprovedShellCommands(); + const command = commands.find( + (entry) => entry?.enabled !== false && entry?.alias === alias, + ); + if (!command) { + throw new Error(`Approved shell command alias is not configured: ${alias}`); + } + + const configuredRoot = String(command.workspaceRoot ?? "").trim(); + if (!configuredRoot) { + throw new Error(`Approved shell command has no workspace root: ${alias}`); + } + + const expectedRoot = resolve(expandHomePath(configuredRoot)); + const actualRoot = resolve(context.root); + if (expectedRoot !== actualRoot) { + throw new Error(`Approved shell command alias is not allowed for this workspace: ${alias}`); + } + + const approvedCwd = resolveAllowedPath( + String(command.workingDirectory ?? "."), + actualRoot, + [actualRoot], + ); + const approvedCommand = String(command.command ?? "").trim(); + if (!approvedCommand) { + throw new Error(`Approved shell command is empty: ${alias}`); + } + + return { + input: { + ...input, + command: approvedCommand, + }, + cwd: approvedCwd, + }; +} + async function runTool( execute: (input: TInput) => Promise>, input: TInput, @@ -119,11 +200,21 @@ export async function listDirectoryTool(input: LsToolInput, context: ToolContext } export async function runShellTool(input: BashToolInput, context: ToolContext): Promise { - const tool = createBashTool(context.cwd); - const timeout = input.timeout === undefined ? 30 : Math.min(input.timeout, 300); + let approved: { input: BashToolInput; cwd: string }; + try { + approved = await resolveApprovedShellAlias(input, context); + } catch (error) { + return { content: formatToolError(error), isError: true }; + } + + const tool = createBashTool(approved.cwd); + const timeout = + approved.input.timeout === undefined + ? 30 + : Math.min(approved.input.timeout, 300); return runTool((params) => tool.execute("run_shell", params), { - command: input.command, + command: approved.input.command, timeout, }, context); } diff --git a/src/register-v11-tools.test.ts b/src/register-v11-tools.test.ts new file mode 100644 index 00000000..ce589c79 --- /dev/null +++ b/src/register-v11-tools.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { loadConfig } from "./config.js"; +import { enabledV11ToolNames } from "./register-v11-tools.js"; + +const baseEnv = { + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", +}; + +assert.deepEqual(enabledV11ToolNames(loadConfig(baseEnv)), []); +assert.deepEqual( + enabledV11ToolNames(loadConfig({ + ...baseEnv, + DEVSPACE_SKILL_MATCHER: "1", + DEVSPACE_COMPOUND_TOOLS: "1", + DEVSPACE_DESIGN_AUDIT: "1", + })), + ["match_skills", "project_snapshot", "focused_context", "review_changes", "design_audit"], +); +assert.deepEqual( + enabledV11ToolNames(loadConfig({ + ...baseEnv, + DEVSPACE_SKILL_MATCHER: "1", + DEVSPACE_SKILLS: "0", + })), + [], +); diff --git a/src/register-v11-tools.ts b/src/register-v11-tools.ts new file mode 100644 index 00000000..9730a8cf --- /dev/null +++ b/src/register-v11-tools.ts @@ -0,0 +1,336 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; +import * as z from "zod/v4"; +import type { ServerConfig } from "./config.js"; +import { + focusedContext, + projectSnapshot, + reviewChanges, + type WorkspaceInspectionContext, +} from "./compound-tools.js"; +import { runDesignAudit } from "./design-audit.js"; +import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; +import { matchWorkspaceSkills } from "./skill-matcher.js"; +import type { Workspace, WorkspaceRegistry } from "./workspaces.js"; + +const metricsSchema = z.object({ + serverDurationMs: z.number().int().nonnegative(), + payloadCharacters: z.number().int().nonnegative(), + returnedItems: z.number().int().nonnegative(), + truncated: z.boolean(), + cacheHit: z.boolean().optional(), +}); + +const readOnlyAnnotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +}; + +export function registerV11Tools( + server: McpServer, + input: { + config: ServerConfig; + workspaces: WorkspaceRegistry; + localAgentProviders: LocalAgentProviderAvailability[]; + }, +): void { + const enabledTools = new Set(enabledV11ToolNames(input.config)); + if (enabledTools.has("match_skills")) { + registerAppTool( + server, + "match_skills", + { + title: "Match skills", + description: "Rank a small set of relevant Skill metadata without loading Skill bodies.", + inputSchema: { + workspaceId: z.string(), + task: z.string().min(1).max(4_000), + limit: z.number().int().min(1).max(10).optional(), + includeGlobal: z.boolean().optional(), + }, + outputSchema: { + matches: z.array(z.object({ + name: z.string(), + shortDescription: z.string(), + path: z.string(), + matchReason: z.string(), + confidence: z.number().min(0).max(1), + requiredTools: z.array(z.string()).optional(), + })), + metrics: metricsSchema, + }, + _meta: {}, + annotations: readOnlyAnnotations, + }, + async ({ workspaceId, task, limit, includeGlobal }) => { + const workspace = input.workspaces.getWorkspace(workspaceId); + const result = await matchWorkspaceSkills({ + skills: workspace.skills, + workspaceRoot: workspace.root, + task, + limit, + includeGlobal, + }); + return toolResult( + result, + result.matches.length === 0 + ? "No relevant skills found." + : `Matched skills: ${result.matches.map((match) => match.name).join(", ")}`, + ); + }, + ); + } + + if (enabledTools.has("project_snapshot")) { + registerProjectSnapshot(server, input); + registerFocusedContext(server, input); + registerReviewChanges(server, input); + } + + if (enabledTools.has("design_audit")) { + registerAppTool( + server, + "design_audit", + { + title: "Design audit", + description: "Run a guarded rendered UI audit through the configured browser adapter.", + inputSchema: { + workspaceId: z.string(), + url: z.string().min(1).max(2_000), + desktopViewport: viewportSchema.optional(), + mobileViewport: viewportSchema.optional(), + routes: z.array(z.string().max(500)).max(20).optional(), + checks: z.array(z.string().max(100)).max(20).optional(), + outputDirectory: z.string().max(1_000).optional(), + }, + outputSchema: { + status: z.enum(["disabled", "unavailable", "completed"]), + adapter: z.string(), + validatedUrl: z.string().optional(), + artifacts: z.array(z.object({ + kind: z.enum(["desktop-screenshot", "mobile-screenshot", "report"]), + path: z.string(), + })), + diagnostics: z.array(z.string()), + consoleErrors: z.number().int().nonnegative().optional(), + overflowIssues: z.number().int().nonnegative().optional(), + accessibilityIssues: z.number().int().nonnegative().optional(), + headingIssues: z.number().int().nonnegative().optional(), + metrics: metricsSchema, + }, + _meta: {}, + annotations: { ...readOnlyAnnotations, openWorldHint: true }, + }, + async ({ workspaceId, ...toolInput }) => { + const workspace = input.workspaces.getWorkspace(workspaceId); + const result = await runDesignAudit(input.config, { + workspaceRoot: workspace.root, + ...toolInput, + }); + return { + ...toolResult(result, result.diagnostics.join(" ")), + isError: result.status !== "completed", + }; + }, + ); + } +} + +export function enabledV11ToolNames(config: ServerConfig): string[] { + return [ + config.skillMatcher && config.skillsEnabled ? "match_skills" : undefined, + ...(config.compoundTools + ? ["project_snapshot", "focused_context", "review_changes"] + : []), + config.designAudit ? "design_audit" : undefined, + ].filter((name): name is string => name !== undefined); +} + +function registerProjectSnapshot( + server: McpServer, + input: { + config: ServerConfig; + workspaces: WorkspaceRegistry; + localAgentProviders: LocalAgentProviderAvailability[]; + }, +): void { + registerAppTool( + server, + "project_snapshot", + { + title: "Project snapshot", + description: "Return a bounded project and Git digest without diff bodies or secret contents.", + inputSchema: { + workspaceId: z.string(), + maxCharacters: z.number().int().min(2_000).max(50_000).optional(), + }, + outputSchema: { + branch: z.string().nullable(), + dirty: z.boolean(), + changedFiles: z.array(z.string()), + diffStat: z.string(), + package: z.object({ + name: z.string().optional(), + version: z.string().optional(), + scripts: z.array(z.string()), + }), + applicableInstructions: z.array(z.string()), + skills: z.array(z.object({ name: z.string(), description: z.string().optional() })), + agentProviders: z.array(agentSummarySchema), + agentProfiles: z.array(agentSummarySchema), + codeGraph: z.object({ + detected: z.boolean(), + available: z.literal(false), + reason: z.enum(["not_initialized", "adapter_unavailable"]), + }), + recommendedTestCommand: z.string().optional(), + recommendedBuildCommand: z.string().optional(), + metrics: metricsSchema, + }, + _meta: {}, + annotations: readOnlyAnnotations, + }, + async ({ workspaceId, maxCharacters }) => { + const workspace = input.workspaces.getWorkspace(workspaceId); + const result = await projectSnapshot( + inspectionContext(workspace, input.localAgentProviders), + { maxCharacters }, + ); + return toolResult( + result, + `Project snapshot: ${result.changedFiles.length} changed file(s), branch ${result.branch ?? "unknown"}.`, + ); + }, + ); +} + +function registerFocusedContext( + server: McpServer, + input: { + workspaces: WorkspaceRegistry; + localAgentProviders: LocalAgentProviderAvailability[]; + }, +): void { + registerAppTool( + server, + "focused_context", + { + title: "Focused context", + description: "Find bounded files, symbols, and match locations for one focus area.", + inputSchema: { + workspaceId: z.string(), + focus: z.string().min(1).max(4_000), + paths: z.array(z.string().max(1_000)).max(25).optional(), + maxFiles: z.number().int().min(1).max(25).optional(), + maxCharacters: z.number().int().min(2_000).max(50_000).optional(), + }, + outputSchema: { + relevantFiles: z.array(z.string()), + relevantSymbols: z.array(z.object({ name: z.string(), path: z.string(), line: z.number().int().positive() })), + searchMatches: z.array(z.object({ path: z.string(), line: z.number().int().positive() })), + applicableInstructions: z.array(z.string()), + impactCandidates: z.array(z.string()), + recommendedReads: z.array(z.string()), + detectionMethod: z.enum(["bounded_text_fallback", "codegraph_adapter_unavailable_fallback"]), + metrics: metricsSchema, + }, + _meta: {}, + annotations: readOnlyAnnotations, + }, + async ({ workspaceId, ...toolInput }) => { + const workspace = input.workspaces.getWorkspace(workspaceId); + const result = await focusedContext( + inspectionContext(workspace, input.localAgentProviders), + toolInput, + ); + return toolResult(result, `Focused context: ${result.relevantFiles.length} relevant file(s).`); + }, + ); +} + +function registerReviewChanges( + server: McpServer, + input: { + workspaces: WorkspaceRegistry; + localAgentProviders: LocalAgentProviderAvailability[]; + }, +): void { + registerAppTool( + server, + "review_changes", + { + title: "Review changes", + description: "Analyze a bounded Git diff without changing files, index, refs, or staging.", + inputSchema: { + workspaceId: z.string(), + scope: z.string().max(1_000).optional(), + baseRef: z.string().max(200).optional(), + maxCharacters: z.number().int().min(2_000).max(50_000).optional(), + }, + outputSchema: { + changedFiles: z.array(z.object({ path: z.string(), status: z.string() })), + diffStat: z.string(), + summary: z.string(), + riskCandidates: z.array(z.string()), + suspiciousChanges: z.array(z.object({ rule: z.string(), file: z.string(), line: z.number().int().positive().optional() })), + testRecommendations: z.array(z.string()), + truncated: z.boolean(), + metrics: metricsSchema, + }, + _meta: {}, + annotations: readOnlyAnnotations, + }, + async ({ workspaceId, ...toolInput }) => { + const workspace = input.workspaces.getWorkspace(workspaceId); + const result = await reviewChanges( + inspectionContext(workspace, input.localAgentProviders), + toolInput, + ); + return toolResult(result, result.summary); + }, + ); +} + +const viewportSchema = z.object({ + width: z.number().int().min(320).max(8_000), + height: z.number().int().min(320).max(8_000), +}); + +const agentSummarySchema = z.object({ + name: z.string(), + provider: z.string(), + available: z.boolean().optional(), + writeMode: z.string().optional(), +}); + +function inspectionContext( + workspace: Workspace, + providers: LocalAgentProviderAvailability[], +): WorkspaceInspectionContext { + return { + root: workspace.root, + instructionPaths: Array.from(workspace.advertisedInstructionPaths).sort(), + skills: workspace.skills + .filter((skill) => !skill.disableModelInvocation) + .map((skill) => ({ name: skill.name, description: skill.description })), + agentProviders: providers.map((provider) => ({ + name: provider.name, + provider: provider.name, + available: provider.available, + })), + agentProfiles: workspace.agentProfiles.map((profile) => ({ + name: profile.name, + provider: profile.provider, + writeMode: profile.writeMode, + })), + }; +} + +function toolResult>(structuredContent: T, summary: string) { + return { + content: [{ type: "text" as const, text: summary.slice(0, 1_000) }], + structuredContent, + }; +} diff --git a/src/safe-inspection.ts b/src/safe-inspection.ts new file mode 100644 index 00000000..20383be9 --- /dev/null +++ b/src/safe-inspection.ts @@ -0,0 +1,66 @@ +import { realpath, stat } from "node:fs/promises"; +import { basename, extname, resolve, sep } from "node:path"; +import { isPathInsideRoot } from "./roots.js"; + +const SECRET_SEGMENTS = new Set([ + ".aws", + ".gnupg", + ".ssh", + "credentials", + "credential", + "secrets", + "secret", +]); +const SECRET_EXTENSIONS = new Set([".key", ".pem", ".p12", ".pfx"]); +const BINARY_EXTENSIONS = new Set([ + ".7z", ".avi", ".bin", ".bmp", ".class", ".dmg", ".doc", ".docx", + ".gif", ".gz", ".ico", ".jar", ".jpeg", ".jpg", ".mov", ".mp3", + ".mp4", ".pdf", ".png", ".tar", ".wasm", ".webp", ".woff", ".woff2", ".zip", +]); +const GENERATED_SEGMENTS = new Set([ + ".git", ".next", ".turbo", "build", "coverage", "dist", "node_modules", +]); + +export function pathSegments(path: string): string[] { + return resolve(path).split(sep).filter(Boolean).map((part) => part.toLowerCase()); +} + +export function isSecretLikePath(path: string): boolean { + const segments = pathSegments(path); + const name = basename(path).toLowerCase(); + return segments.some((part) => SECRET_SEGMENTS.has(part)) + || name === ".env" + || name.startsWith(".env.") + || name === "id_rsa" + || name === "id_ed25519" + || /(?:^|[-_.])(token|credentials?|secrets?)(?:[-_.]|$)/.test(name) + || SECRET_EXTENSIONS.has(extname(name)); +} + +export function isGeneratedOrBinaryPath(path: string): boolean { + const segments = pathSegments(path); + return segments.some((part) => GENERATED_SEGMENTS.has(part)) + || BINARY_EXTENSIONS.has(extname(path).toLowerCase()); +} + +export function containsSecretValue(text: string): boolean { + return /-----BEGIN [A-Z ]*PRIVATE KEY-----/i.test(text) + || /\b(?:bearer|api[_-]?key|access[_-]?token|client[_-]?secret)\s*[:=]\s*["']?[A-Za-z0-9_./+=-]{12,}/i.test(text); +} + +export function redactSensitiveText(text: string): string { + return text + .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gi, "[REDACTED PRIVATE KEY]") + .replace(/(\b(?:bearer|api[_-]?key|access[_-]?token|client[_-]?secret)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]"); +} + +export async function safeRealFile(path: string, root: string): Promise { + try { + const [realFile, realRoot] = await Promise.all([realpath(path), realpath(root)]); + if (!isPathInsideRoot(realFile, realRoot)) return undefined; + if (!(await stat(realFile)).isFile()) return undefined; + return realFile; + } catch { + return undefined; + } +} diff --git a/src/server.ts b/src/server.ts index c72e1434..93edc39c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,6 +35,13 @@ import { runShellTool, writeFileTool, } from "./pi-tools.js"; +import { + appendUsageToContent, + editInputChars, + estimateFileChars, + recordObservedToolUsage, + textContentChars, +} from "./usage-meter.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; @@ -47,6 +54,7 @@ import { getLocalAgentProviderAvailabilitySnapshot, type LocalAgentProviderAvailability, } from "./local-agent-availability.js"; +import { registerV11Tools } from "./register-v11-tools.js"; type Transport = StreamableHTTPServerTransport; const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; @@ -187,7 +195,9 @@ function serverInstructions(config: ServerConfig): string { ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` : ""; - const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; + const agentsMd = config.openWorkspacePayload === "compact" + ? `Follow instructions returned by ${toolNames.openWorkspace}. It returns bounded instruction excerpts to keep the initial result small; use ${toolNames.read} to read every path listed in agentsFiles before other project work, and read applicable paths in availableAgentsFiles before working in their scope. ` + : `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChangesInstruction}`; } @@ -225,13 +235,15 @@ function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { const workspaceSkillOutputSchema = z.object({ name: z.string(), - description: z.string(), + description: z.string().optional(), path: z.string(), }); const workspaceAgentsFileOutputSchema = z.object({ path: z.string(), - content: z.string(), + content: z.string().optional(), + characters: z.number().int().nonnegative().optional(), + truncated: z.boolean().optional(), }); const workspaceLocalAgentOutputSchema = z.object({ @@ -240,6 +252,7 @@ const workspaceLocalAgentOutputSchema = z.object({ provider: z.string(), model: z.string().optional(), thinking: z.string().optional(), + writeMode: z.enum(["read_only", "allowed"]).optional(), providerAvailable: z.boolean().optional(), providerUnavailableReason: z.string().optional(), }); @@ -335,6 +348,23 @@ function textBlock(text: string): ToolContent { return { type: "text", text }; } +function compactInstructionContent(content: string, limit: number): { + content: string; + truncated: boolean; +} { + if (content.length <= limit) return { content, truncated: false }; + + const marker = "\n\n[... instruction file truncated by GPT-5.6 compact mode; use read with this path for the full file ...]\n\n"; + const available = Math.max(0, limit - marker.length); + const headLength = Math.ceil(available * 0.7); + const tailLength = available - headLength; + + return { + content: `${content.slice(0, headLength)}${marker}${content.slice(content.length - tailLength)}`, + truncated: true, + }; +} + function textSummary(content: ToolContent[]): { lines: number; characters: number; @@ -730,7 +760,7 @@ function createMcpServer( { title: "Open workspace", description: - "Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder; do not call open_workspace again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for an isolated or parallel coding session. Returns a workspaceId, loaded root project instructions, and nested instruction file paths the model should read before working in those directories.", + "Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder. In compact mode, instruction files are returned as bounded excerpts and can be read in full through the read tool.", inputSchema: { path: z .string() @@ -765,31 +795,49 @@ function createMcpServer( .optional(), agentsFiles: z.array(workspaceAgentsFileOutputSchema), availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), + availableAgentsFilesTotal: z.number().int().nonnegative(), + availableAgentsFilesTruncated: z.boolean(), skills: z.array(workspaceSkillOutputSchema), agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), agents: z.array(workspaceLocalAgentOutputSchema), skillDiagnostics: z.array(z.unknown()), instruction: z.string(), + metrics: z.object({ + compact: z.boolean(), + serverDurationMs: z.number().int().nonnegative(), + payloadCharacters: z.number().int().nonnegative(), + fullInstructionCharacters: z.number().int().nonnegative(), + returnedInstructionCharacters: z.number().int().nonnegative(), + }), }, ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, }, async ({ path, mode, baseRef }) => { const startedAt = performance.now(); + const compact = config.openWorkspacePayload === "compact"; const { workspace, agentsFiles, availableAgentsFiles } = await workspaces.openWorkspace({ path, mode, baseRef }); + if (config.widgets === "changes") { void reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, }); } + const visibleSkills = workspace.skills .filter((skill) => !skill.disableModelInvocation) - .map((skill) => ({ - name: skill.name, - description: skill.description, - path: formatPathForPrompt(skill.filePath), - })); + .map((skill) => compact + ? { + name: skill.name, + path: formatPathForPrompt(skill.filePath), + } + : { + name: skill.name, + description: skill.description, + path: formatPathForPrompt(skill.filePath), + }); + const visibleAgentProviders = config.subagents ? localAgentProviders : []; const visibleAgents = workspace.agentProfiles.map((profile) => { const summary = summarizeLocalAgentProfile(profile); @@ -800,16 +848,38 @@ function createMcpServer( providerUnavailableReason: availability?.reason, }; }); - const loadedAgentsFiles = agentsFiles.map((file) => ({ - path: formatAgentsPath(file.path, workspace.root), - content: file.content, - })); + + const loadedAgentsFiles = agentsFiles.map((file) => { + const formattedPath = formatAgentsPath(file.path, workspace.root); + if (!compact) { + return { + path: formattedPath, + content: file.content, + }; + } + + const excerpt = compactInstructionContent( + file.content, + config.openWorkspaceInstructionChars, + ); + return { + path: formattedPath, + content: excerpt.content, + characters: file.content.length, + truncated: excerpt.truncated, + }; + }); + const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); - const instruction = config.skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + + const instruction = compact + ? "Use this workspaceId for later calls in this project. Treat agentsFiles content as excerpts and read every listed path in full before other project work. Read applicable availableAgentsFiles before working in their nested scope. Read a skill only when the task matches it." + : config.skillsEnabled + ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." + : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + const resultContent: ToolContent[] = [ { type: "text" as const, @@ -817,34 +887,74 @@ function createMcpServer( `Opened workspace ${workspace.id}`, `Root: ${workspace.root}`, `Mode: ${workspace.mode}`, + compact ? "Payload: compact" : "Payload: full", loadedAgentsFiles.length > 0 - ? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` + ? compact + ? `Instruction files: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` + : `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` : undefined, availableAgentsFileOutputs.length > 0 - ? `Available nested instructions: ${availableAgentsFileOutputs.map((file) => file.path).join(", ")}` + ? compact + ? `Nested instruction files: ${availableAgentsFileOutputs.length}` + : `Available nested instructions: ${availableAgentsFileOutputs.map((file) => file.path).join(", ")}` : undefined, visibleSkills.length > 0 - ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` + ? `${compact ? "Skills" : "Available skills"}: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, - visibleAgentProviders.some((provider) => provider.available) + !compact && visibleAgentProviders.some((provider) => provider.available) ? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map((provider) => provider.name).join(", ")}` : undefined, - visibleAgentProviders.some((provider) => !provider.available) + !compact && visibleAgentProviders.some((provider) => !provider.available) ? `Unavailable subagent providers: ${visibleAgentProviders.filter((provider) => !provider.available).map(formatUnavailableAgentProvider).join(", ")}` : undefined, - visibleAgents.length > 0 + !compact && visibleAgents.length > 0 ? `Available subagent profiles: ${visibleAgents.map(formatVisibleAgent).join(", ")}` : undefined, instruction, ].filter(Boolean).join("\n"), }, ]; + + const fullInstructionCharacters = agentsFiles.reduce( + (total, file) => total + file.content.length, + 0, + ); + const returnedInstructionCharacters = loadedAgentsFiles.reduce( + (total, file) => total + (file.content?.length ?? 0), + 0, + ); + const serverDurationMs = Math.round(performance.now() - startedAt); + const structuredContent = { + workspaceId: workspace.id, + root: workspace.root, + mode: workspace.mode, + sourceRoot: workspace.sourceRoot, + worktree: workspace.worktree, + agentsFiles: loadedAgentsFiles, + availableAgentsFiles: availableAgentsFileOutputs, + availableAgentsFilesTotal: availableAgentsFiles.length, + availableAgentsFilesTruncated: availableAgentsFileOutputs.length < availableAgentsFiles.length, + skills: visibleSkills, + agentProviders: visibleAgentProviders, + agents: visibleAgents, + skillDiagnostics: compact ? [] : workspace.skillDiagnostics, + instruction, + metrics: { + compact, + serverDurationMs, + payloadCharacters: 0, + fullInstructionCharacters, + returnedInstructionCharacters, + }, + }; + structuredContent.metrics.payloadCharacters = JSON.stringify(structuredContent).length; + logToolCall(config, { tool: "open_workspace", workspaceId: workspace.id, path: workspace.root, success: true, - durationMs: Math.round(performance.now() - startedAt), + durationMs: serverDurationMs, }); return { @@ -856,29 +966,21 @@ function createMcpServer( root: workspace.root, path: workspace.root, summary: { + compact, + payloadCharacters: structuredContent.metrics.payloadCharacters, + fullInstructionCharacters, + returnedInstructionCharacters, agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, + availableAgentsFilesTotal: availableAgentsFiles.length, skills: visibleSkills.length, agentProviders: visibleAgentProviders.length, agents: visibleAgents.length, - skillDiagnostics: workspace.skillDiagnostics.length, + skillDiagnostics: compact ? 0 : workspace.skillDiagnostics.length, }, }, }, - structuredContent: { - workspaceId: workspace.id, - root: workspace.root, - mode: workspace.mode, - sourceRoot: workspace.sourceRoot, - worktree: workspace.worktree, - agentsFiles: loadedAgentsFiles, - availableAgentsFiles: availableAgentsFileOutputs, - skills: visibleSkills, - agentProviders: visibleAgentProviders, - agents: visibleAgents, - skillDiagnostics: workspace.skillDiagnostics, - instruction, - }, + structuredContent, }; }, ); @@ -954,6 +1056,19 @@ function createMcpServer( offset: input.offset ?? 1, limited: input.limit !== undefined, }; + const observedChars = textContentChars(response.content); + const savedChars = input.offset !== undefined || input.limit !== undefined + ? Math.max(0, estimateFileChars(readPath.absolutePath) - observedChars) + : 0; + const usage = recordObservedToolUsage({ + tool: toolNames.read, + workspaceId, + workspaceRoot: workspace.root, + path: input.path, + observedChars, + savedChars, + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { tool: toolNames.read, workspaceId, @@ -964,17 +1079,18 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.read, card: { workspaceId, path: input.path, summary, - payload: { content: response.content }, + payload: { content }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1026,6 +1142,15 @@ function createMcpServer( lines: contentLineCount(input.content), characters: input.content.length, }; + const usage = recordObservedToolUsage({ + tool: toolNames.write, + workspaceId, + workspaceRoot: workspace.root, + path: input.path, + observedChars: input.content.length + textContentChars(response.content), + savedChars: 0, + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { tool: toolNames.write, workspaceId, @@ -1036,6 +1161,7 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.write, card: { @@ -1043,13 +1169,13 @@ function createMcpServer( path: input.path, summary, payload: { - content: response.content, + content, patch, }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1091,7 +1217,7 @@ function createMcpServer( async ({ workspaceId, ...input }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); - workspaces.resolvePath(workspace, input.path); + const targetPath = workspaces.resolvePath(workspace, input.path); const response = await editFileTool(input, { cwd: workspace.root, root: workspace.root, @@ -1115,6 +1241,17 @@ function createMcpServer( }; const editResultText = `Edited ${input.path} (+${stats.additions} -${stats.removals}).`; const editContent = [textBlock(editResultText)]; + const observedChars = editInputChars(input.edits) + textContentChars(editContent); + const savedChars = Math.max(0, estimateFileChars(targetPath) * 2 - observedChars); + const usage = recordObservedToolUsage({ + tool: toolNames.edit, + workspaceId, + workspaceRoot: workspace.root, + path: input.path, + observedChars, + savedChars, + }); + const content = appendUsageToContent(editContent, usage, config.usageContent); logToolCall(config, { tool: toolNames.edit, workspaceId, @@ -1124,7 +1261,7 @@ function createMcpServer( }); return { - content: editContent, + content, _meta: { tool: toolNames.edit, card: { @@ -1139,7 +1276,7 @@ function createMcpServer( }, structuredContent: { status: "applied", - result: contentText(editContent), + result: contentText(content), }, }; }, @@ -1324,6 +1461,19 @@ function createMcpServer( scope: input.path ?? ".", ...textSummary(response.content), }; + const usage = recordObservedToolUsage({ + tool: toolNames.grep, + workspaceId, + workspaceRoot: workspace.root, + path: input.path, + observedChars: + String(input.pattern ?? "").length + + String(input.path ?? "").length + + String(input.include ?? "").length + + textContentChars(response.content), + savedChars: 0, + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { tool: toolNames.grep, workspaceId, @@ -1334,17 +1484,18 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.grep, card: { workspaceId, path: input.path, summary, - payload: { content: response.content }, + payload: { content }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1394,6 +1545,18 @@ function createMcpServer( scope: input.path ?? ".", ...textSummary(response.content), }; + const usage = recordObservedToolUsage({ + tool: toolNames.glob, + workspaceId, + workspaceRoot: workspace.root, + path: input.path, + observedChars: + String(input.pattern ?? "").length + + String(input.path ?? "").length + + textContentChars(response.content), + savedChars: 0, + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { tool: toolNames.glob, workspaceId, @@ -1404,17 +1567,18 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.glob, card: { workspaceId, path: input.path, summary, - payload: { content: response.content }, + payload: { content }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1460,6 +1624,15 @@ function createMcpServer( } const summary = textSummary(response.content); + const usage = recordObservedToolUsage({ + tool: toolNames.ls, + workspaceId, + workspaceRoot: workspace.root, + path: input.path, + observedChars: String(input.path ?? "").length + textContentChars(response.content), + savedChars: 0, + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { tool: toolNames.ls, workspaceId, @@ -1470,17 +1643,18 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.ls, card: { workspaceId, path: input.path, summary, - payload: { content: response.content }, + payload: { content }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1550,6 +1724,15 @@ function createMcpServer( workingDirectory: workingDirectory ?? ".", ...textSummary(response.content), }; + const usage = recordObservedToolUsage({ + tool: toolNames.shell, + workspaceId, + workspaceRoot: workspace.root, + path: workingDirectory ?? ".", + observedChars: input.command.length + textContentChars(response.content), + savedChars: 0, + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { tool: toolNames.shell, workspaceId, @@ -1562,17 +1745,18 @@ function createMcpServer( return { ...response, + content, _meta: { tool: toolNames.shell, card: { workspaceId, path: workingDirectory, summary, - payload: { content: response.content }, + payload: { content }, }, }, structuredContent: { - result: contentText(response.content), + result: contentText(content), }, }; }, @@ -1583,6 +1767,8 @@ function createMcpServer( registerCodexProcessTools(server, config, workspaces, processSessions); } + registerV11Tools(server, { config, workspaces, localAgentProviders }); + return server; } diff --git a/src/skill-matcher.test.ts b/src/skill-matcher.test.ts new file mode 100644 index 00000000..ca400831 --- /dev/null +++ b/src/skill-matcher.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Skill } from "@earendil-works/pi-coding-agent"; +import { matchWorkspaceSkills } from "./skill-matcher.js"; +import { resolveSkillReadPath } from "./skills.js"; + +const root = await mkdtemp(join(tmpdir(), "gpt-agent-skill-matcher-test-")); +const workspaceRoot = join(root, "project"); +const globalRoot = join(root, "global"); + +try { + await mkdir(workspaceRoot, { recursive: true }); + await mkdir(globalRoot, { recursive: true }); + const projectSkill = await skill(workspaceRoot, "dashboard-audit", [ + "---", + "name: dashboard-audit", + "description: Audit dashboard accessibility and responsive layout.", + "short-description: Check dashboard UI accessibility.", + "triggers:", + " - dashboard audit", + " - accessibility review", + "required-tools:", + " - design_audit", + " - invalid tool value", + "---", + "Body must not be returned.", + ].join("\n")); + const otherSkill = await skill(workspaceRoot, "database-migration", [ + "---", + "name: database-migration", + "description: Migrate relational database schemas.", + "---", + "Database body.", + ].join("\n")); + const globalSkill = await skill(globalRoot, "global-dashboard", [ + "---", + "name: global-dashboard", + "description: Review global dashboard navigation.", + "---", + "Global body.", + ].join("\n")); + const disabledSkill = { ...otherSkill, name: "disabled-dashboard", disableModelInvocation: true }; + const skills = [projectSkill, otherSkill, globalSkill, disabledSkill]; + + const first = await matchWorkspaceSkills({ + skills, + workspaceRoot, + task: "Run a dashboard audit and accessibility review", + limit: 1, + }); + assert.deepEqual(first.matches.map((match) => match.name), ["dashboard-audit"]); + assert.deepEqual(first.matches[0]?.requiredTools, ["design_audit"]); + assert.equal(first.metrics.cacheHit, false); + assert.equal(first.metrics.returnedItems, 1); + assert.equal(first.metrics.payloadCharacters, JSON.stringify(first).length); + assert.equal(JSON.stringify(first).includes("Body must not be returned"), false); + assert.equal( + resolveSkillReadPath(skills, new Set(), first.matches[0]!.path)?.absolutePath, + projectSkill.filePath, + ); + + const second = await matchWorkspaceSkills({ + skills, + workspaceRoot, + task: "Review the dashboard navigation", + includeGlobal: true, + limit: 1, + }); + assert.equal(second.metrics.cacheHit, true); + assert.equal(second.metrics.truncated, true); + assert.equal(second.matches.length, 1); + + const none = await matchWorkspaceSkills({ + skills, + workspaceRoot, + task: "compile a Rust kernel module", + }); + assert.deepEqual(none.matches, []); + assert.equal(none.metrics.returnedItems, 0); + + const designBase = join(process.cwd(), "skills", "design-system-audit"); + const designSkill = { + name: "design-system-audit", + description: "Audit rendered interface design systems.", + baseDir: designBase, + filePath: join(designBase, "SKILL.md"), + disableModelInvocation: false, + } as Skill; + const designMatch = await matchWorkspaceSkills({ + skills: [designSkill], + workspaceRoot, + task: "Run a design system audit", + includeGlobal: true, + }); + assert.deepEqual(designMatch.matches[0]?.requiredTools, [ + "design_audit", + "focused_context", + "read", + ]); +} finally { + await rm(root, { recursive: true, force: true }); +} + +async function skill(base: string, name: string, content: string): Promise { + const baseDir = join(base, ".agents", "skills", name); + const filePath = join(baseDir, "SKILL.md"); + await mkdir(baseDir, { recursive: true }); + await writeFile(filePath, content); + return { + name, + description: content.match(/description:\s*([^\n]+)/)?.[1] ?? name, + filePath, + baseDir, + disableModelInvocation: false, + } as Skill; +} diff --git a/src/skill-matcher.ts b/src/skill-matcher.ts new file mode 100644 index 00000000..f51d9b22 --- /dev/null +++ b/src/skill-matcher.ts @@ -0,0 +1,276 @@ +import { open, realpath, type FileHandle } from "node:fs/promises"; +import { parse as parseYaml } from "yaml"; +import type { Skill } from "@earendil-works/pi-coding-agent"; +import { formatPathForPrompt } from "./skills.js"; +import { isPathInsideRoot } from "./roots.js"; +import { containsSecretValue, isSecretLikePath, safeRealFile } from "./safe-inspection.js"; +import { + buildBoundedPayload, + clampInteger, + takeWithinCharacterBudget, + type ToolMetrics, +} from "./tool-metrics.js"; + +export interface SkillMatch { + name: string; + shortDescription: string; + path: string; + matchReason: string; + confidence: number; + requiredTools?: string[]; +} + +export interface SkillMatchResult extends Record { + matches: SkillMatch[]; + metrics: ToolMetrics; +} + +interface IndexedSkill { + match: Omit; + normalizedName: string; + nameTokens: Set; + descriptionTokens: Set; + triggers: string[]; + triggerTokens: Set; + global: boolean; +} + +const FRONTMATTER_BYTES = 16 * 1024; +const DEFAULT_LIMIT = 3; +const MAX_LIMIT = 10; +const MATCH_PAYLOAD_CHARACTERS = 8_000; +const metadataCache = new WeakMap>(); +const STOP_WORDS = new Set([ + "and", "for", "from", "into", "the", "this", "that", "use", "with", + "your", "task", "work", "using", "when", "where", "what", "how", +]); + +export async function matchWorkspaceSkills(input: { + skills: Skill[]; + workspaceRoot: string; + task: string; + limit?: number; + includeGlobal?: boolean; +}): Promise { + const startedAt = performance.now(); + const cacheHit = metadataCache.has(input.skills); + let indexPromise = metadataCache.get(input.skills); + if (!indexPromise) { + indexPromise = buildSkillIndex(input.skills, input.workspaceRoot); + metadataCache.set(input.skills, indexPromise); + } + + const index = await indexPromise; + const limit = clampInteger(input.limit, DEFAULT_LIMIT, 1, MAX_LIMIT); + const task = normalize(input.task).slice(0, 2_000); + const taskTokens = tokens(task); + const candidates = index + .filter((skill) => input.includeGlobal === true || !skill.global) + .map((skill) => scoreSkill(skill, task, taskTokens)) + .filter((candidate): candidate is SkillMatch => candidate !== undefined) + .sort((a, b) => b.confidence - a.confidence || a.name.localeCompare(b.name)); + const limited = candidates.slice(0, limit); + + return buildBoundedPayload({ + startedAt, + maxCharacters: MATCH_PAYLOAD_CHARACTERS, + cacheHit, + build: (contentBudget) => { + const bounded = takeWithinCharacterBudget(limited, contentBudget); + return { + payload: { matches: bounded.items }, + returnedItems: bounded.items.length, + truncated: candidates.length > limited.length || bounded.truncated, + }; + }, + }); +} + +async function buildSkillIndex(skills: Skill[], workspaceRoot: string): Promise { + let realWorkspaceRoot: string; + try { + realWorkspaceRoot = await realpath(workspaceRoot); + } catch { + return []; + } + + const indexed = await Promise.all(skills.map(async (skill): Promise => { + if (skill.disableModelInvocation || isSecretLikePath(skill.filePath)) return undefined; + const realFile = await safeRealFile(skill.filePath, skill.baseDir); + if (!realFile) return undefined; + + const frontmatter = await readBoundedFrontmatter(realFile); + const shortDescription = readShortDescription(frontmatter) ?? skill.description.trim(); + const triggers = readStringList( + frontmatter, + ["triggers", "__body-triggers"], + 12, + 120, + ); + const requiredTools = readStringList( + frontmatter, + ["required-tools", "requiredTools", "__body-required-tools"], + 12, + 64, + ).filter((tool) => /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(tool)); + + if (!shortDescription || containsSecretValue(shortDescription) || triggers.some(containsSecretValue)) { + return undefined; + } + + const normalizedName = normalize(skill.name.replaceAll("-", " ")); + return { + match: { + name: skill.name.slice(0, 100), + shortDescription: shortDescription.slice(0, 240), + path: formatPathForPrompt(skill.filePath), + ...(requiredTools.length > 0 ? { requiredTools } : {}), + }, + normalizedName, + nameTokens: tokens(normalizedName), + descriptionTokens: tokens(shortDescription), + triggers: triggers.map(normalize), + triggerTokens: tokens(triggers.join(" ")), + global: !isPathInsideRoot(realFile, realWorkspaceRoot), + }; + })); + + return indexed.filter((skill): skill is IndexedSkill => skill !== undefined); +} + +function scoreSkill( + skill: IndexedSkill, + task: string, + taskTokens: Set, +): SkillMatch | undefined { + const matchedFields: string[] = []; + let score = 0; + + const triggerPhrase = skill.triggers.find((trigger) => + trigger.length >= 3 && (task.includes(trigger) || trigger.includes(task)) + ); + if (triggerPhrase) { + score += 0.62; + matchedFields.push("trigger"); + } + + if (skill.normalizedName.length >= 3 && task.includes(skill.normalizedName)) { + score += 0.55; + matchedFields.push("name"); + } + + const nameOverlap = overlap(taskTokens, skill.nameTokens); + if (nameOverlap > 0) { + score += Math.min(0.38, nameOverlap * 0.19); + if (!matchedFields.includes("name")) matchedFields.push("name"); + } + + const triggerOverlap = overlap(taskTokens, skill.triggerTokens); + if (triggerOverlap > 0) { + score += Math.min(0.34, triggerOverlap * 0.17); + if (!matchedFields.includes("trigger")) matchedFields.push("trigger"); + } + + const descriptionOverlap = overlap(taskTokens, skill.descriptionTokens); + if (descriptionOverlap > 0) { + score += Math.min(0.3, descriptionOverlap * 0.1); + matchedFields.push("description"); + } + + if (score < 0.2) return undefined; + const confidence = Math.min(0.99, Math.round(score * 100) / 100); + return { + ...skill.match, + matchReason: `Matched ${Array.from(new Set(matchedFields)).join(" + ")}.`, + confidence, + }; +} + +function overlap(left: Set, right: Set): number { + let count = 0; + for (const token of left) { + if (right.has(token)) count += 1; + } + return count; +} + +function normalize(value: string): string { + return value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim(); +} + +function tokens(value: string): Set { + const output = new Set(); + const normalized = normalize(value); + const segmenter = new Intl.Segmenter(undefined, { granularity: "word" }); + for (const segment of segmenter.segment(normalized)) { + if (!segment.isWordLike) continue; + const token = segment.segment.trim(); + if (token.length < 2 || STOP_WORDS.has(token)) continue; + output.add(token); + } + return output; +} + +async function readBoundedFrontmatter(path: string): Promise> { + let handle: FileHandle | undefined; + try { + handle = await open(path, "r"); + const buffer = Buffer.alloc(FRONTMATTER_BYTES); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const source = buffer.subarray(0, bytesRead).toString("utf8").replace(/^\uFEFF/, ""); + const lines = source.split(/\r?\n/); + if (lines[0]?.trim() !== "---") return {}; + const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---"); + if (end === -1) return {}; + const parsed = parseYaml(lines.slice(1, end).join("\n")); + const frontmatter = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; + const bodyLines = lines.slice(end + 1); + return { + ...frontmatter, + "__body-triggers": readMarkdownList(bodyLines, "Triggers"), + "__body-required-tools": readMarkdownList(bodyLines, "Required tools") + .map((value) => value.replace(/^`|`$/g, "")), + }; + } catch { + return {}; + } finally { + await handle?.close(); + } +} + +function readMarkdownList(lines: string[], heading: string): string[] { + const start = lines.findIndex((line) => line.trim().toLowerCase() === `## ${heading.toLowerCase()}`); + if (start === -1) return []; + const values: string[] = []; + for (const line of lines.slice(start + 1)) { + if (/^##\s+/.test(line.trim())) break; + const match = line.match(/^\s*[-*]\s+(.+?)\s*$/); + if (match?.[1]) values.push(match[1]); + } + return values; +} + +function readShortDescription(frontmatter: Record): string | undefined { + for (const key of ["short-description", "shortDescription"]) { + const value = frontmatter[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +function readStringList( + frontmatter: Record, + keys: string[], + maxItems: number, + maxCharacters: number, +): string[] { + const value = keys.map((key) => frontmatter[key]).find((candidate) => candidate !== undefined); + const values = typeof value === "string" ? [value] : Array.isArray(value) ? value : []; + return values + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim().slice(0, maxCharacters)) + .filter(Boolean) + .slice(0, maxItems); +} diff --git a/src/skills.test.ts b/src/skills.test.ts index 707dda26..e2b69fa8 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -209,6 +209,22 @@ try { true, ); + const designConfig = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_AGENT_DIR: agentDir, + DEVSPACE_DESIGN_AUDIT: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", + }); + const designSkills = loadWorkspaceSkills(designConfig, projectRoot).skills; + for (const name of [ + "design-system-audit", + "responsive-accessibility-audit", + "product-ui-review", + ]) { + assert.ok(designSkills.some((skill) => skill.name === name)); + } + const duplicateConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: projectRoot, DEVSPACE_AGENT_DIR: agentDir, diff --git a/src/skills.ts b/src/skills.ts index c1f146a9..a63bea15 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -39,7 +39,7 @@ export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] resolve(cwd, ".agents", "skills"), config.devspaceSkillsDir, join(config.agentDir, "skills"), - config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir) + (config.designAudit || (config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir))) ? bundledSkills : undefined, ]; diff --git a/src/tool-metrics.ts b/src/tool-metrics.ts new file mode 100644 index 00000000..8e2970c2 --- /dev/null +++ b/src/tool-metrics.ts @@ -0,0 +1,92 @@ +export interface ToolMetrics { + serverDurationMs: number; + payloadCharacters: number; + returnedItems: number; + truncated: boolean; + cacheHit?: boolean; +} + +export interface BoundedBuild { + payload: T; + returnedItems: number; + truncated: boolean; +} + +export function measuredPayload( + payload: T, + input: { + startedAt: number; + returnedItems: number; + truncated: boolean; + cacheHit?: boolean; + }, +): T & { metrics: ToolMetrics } { + const metrics: ToolMetrics = { + serverDurationMs: Math.max(0, Math.round(performance.now() - input.startedAt)), + payloadCharacters: 0, + returnedItems: input.returnedItems, + truncated: input.truncated, + ...(input.cacheHit === undefined ? {} : { cacheHit: input.cacheHit }), + }; + const result = { ...payload, metrics }; + + for (let attempt = 0; attempt < 4; attempt += 1) { + const characters = JSON.stringify(result).length; + if (metrics.payloadCharacters === characters) break; + metrics.payloadCharacters = characters; + } + + return result; +} + +export function buildBoundedPayload(input: { + startedAt: number; + maxCharacters: number; + cacheHit?: boolean; + build(contentBudget: number): BoundedBuild; +}): T & { metrics: ToolMetrics } { + let contentBudget = Math.max(0, input.maxCharacters - 512); + let result: T & { metrics: ToolMetrics }; + + for (let attempt = 0; attempt < 5; attempt += 1) { + const built = input.build(contentBudget); + result = measuredPayload(built.payload, { + startedAt: input.startedAt, + returnedItems: built.returnedItems, + truncated: built.truncated, + cacheHit: input.cacheHit, + }); + if (result.metrics.payloadCharacters <= input.maxCharacters) return result; + contentBudget = Math.max( + 0, + contentBudget - (result.metrics.payloadCharacters - input.maxCharacters) - 64, + ); + } + + throw new Error(`Unable to fit tool payload within ${input.maxCharacters} characters.`); +} + +export function takeWithinCharacterBudget( + items: T[], + budget: number, +): { items: T[]; truncated: boolean } { + const selected: T[] = []; + let used = 2; + for (const item of items) { + const characters = JSON.stringify(item).length + (selected.length > 0 ? 1 : 0); + if (used + characters > budget) return { items: selected, truncated: true }; + selected.push(item); + used += characters; + } + return { items: selected, truncated: false }; +} + +export function clampInteger( + value: number | undefined, + fallback: number, + minimum: number, + maximum: number, +): number { + if (value === undefined) return fallback; + return Math.min(maximum, Math.max(minimum, Math.floor(value))); +} diff --git a/src/usage-meter.test.ts b/src/usage-meter.test.ts new file mode 100644 index 00000000..448dc8e1 --- /dev/null +++ b/src/usage-meter.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { platform } from "node:os"; +import { + appendUsageToContent, + compactTokenCount, + editInputChars, + estimateTokensFromChars, + recordObservedToolUsage, + textContentChars, +} from "./usage-meter.js"; + +assert.equal(estimateTokensFromChars(0), 0); +assert.equal(estimateTokensFromChars(5), 2); +assert.equal(compactTokenCount(999), "999"); +assert.equal(compactTokenCount(1_500), "1.5k"); +assert.equal(editInputChars([{ oldText: "old", newText: "new" }]), 6); +assert.equal(textContentChars([{ type: "text", text: "hello" }]), 5); + +const previousHistory = process.env.DEVSPACE_USAGE_HISTORY; +process.env.DEVSPACE_USAGE_HISTORY = platform() === "win32" ? "NUL" : "/dev/null"; +const usage = recordObservedToolUsage({ + tool: "read", + observedChars: 40, + savedChars: 80, +}); +const content = [{ type: "text" as const, text: "result" }]; + +assert.deepEqual(appendUsageToContent(content, usage, "off"), content); +const compactContent = appendUsageToContent(content, usage, "compact"); +const compactText = compactContent.at(-1); +assert.match( + compactText?.type === "text" ? compactText.text : "", + /DevSpace token estimate/, +); +const fullContent = appendUsageToContent(content, usage, "full"); +const fullText = fullContent.at(-1); +assert.match( + fullText?.type === "text" ? fullText.text : "", + /not actual model usage or billing data/, +); + +if (previousHistory === undefined) { + delete process.env.DEVSPACE_USAGE_HISTORY; +} else { + process.env.DEVSPACE_USAGE_HISTORY = previousHistory; +} diff --git a/src/usage-meter.ts b/src/usage-meter.ts new file mode 100644 index 00000000..83f6b84d --- /dev/null +++ b/src/usage-meter.ts @@ -0,0 +1,176 @@ +import { statSync } from "node:fs"; +import { appendFile, mkdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { UsageContentMode } from "./config.js"; + +type TextContent = { type: "text"; text: string }; +type ToolContent = TextContent | { type: "image"; data: string; mimeType: string }; + +interface UsageBucket { + observedTokens: number; + savedTokens: number; + calls: number; +} + +export interface UsageEntry { + ts: string; + sessionPid: number; + workspaceId?: string; + workspaceRoot?: string; + workspaceName?: string; + tool: string; + path?: string; + observedChars: number; + observedTokens: number; + savedChars: number; + savedTokens: number; + sessionObservedTokens: number; + sessionSavedTokens: number; + byTool: Record; + note: string; +} + +const session = { + observedTokens: 0, + savedTokens: 0, + byTool: new Map(), +}; +let historyWrite = Promise.resolve(); + +function historyPath(): string { + return process.env.DEVSPACE_USAGE_HISTORY + ?? join(homedir(), ".local", "share", "devspace", "usage-history.jsonl"); +} + +function clampNumber(value: number): number { + return Number.isFinite(value) && value > 0 ? Math.ceil(value) : 0; +} + +export function estimateTokensFromChars(chars: number): number { + return Math.ceil(clampNumber(chars) / 4); +} + +export function textContentChars(content: ToolContent[]): number { + return content + .filter((item): item is TextContent => item?.type === "text" && typeof item.text === "string") + .reduce((total, item) => total + item.text.length, 0); +} + +export function estimateFileChars(path: string): number { + try { + const stats = statSync(path); + return stats.isFile() ? stats.size : 0; + } catch { + return 0; + } +} + +export function editInputChars(edits: Array<{ oldText?: string; newText?: string }>): number { + return edits.reduce( + (total, edit) => + total + String(edit.oldText ?? "").length + String(edit.newText ?? "").length, + 0, + ); +} + +export function compactTokenCount(tokens: number): string { + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(2)}M`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; + return `${tokens}`; +} + +function byToolSnapshot(): Record { + return Object.fromEntries( + Array.from(session.byTool.entries()).sort(([a], [b]) => a.localeCompare(b)), + ); +} + +function appendHistory(entry: UsageEntry): void { + const file = historyPath(); + historyWrite = historyWrite.then(async () => { + await mkdir(dirname(file), { recursive: true }); + await appendFile(file, `${JSON.stringify(entry)}\n`, "utf8"); + }).catch(() => { + // Diagnostic only. Never break the actual tool result. + }); +} + +export function recordObservedToolUsage(input: { + tool?: string; + workspaceId?: string; + workspaceRoot?: string; + path?: string; + observedChars: number; + savedChars: number; +}): UsageEntry { + const tool = input.tool ?? "unknown"; + const observedChars = clampNumber(input.observedChars); + const savedChars = clampNumber(input.savedChars); + const observedTokens = estimateTokensFromChars(observedChars); + const savedTokens = estimateTokensFromChars(savedChars); + const current = session.byTool.get(tool) ?? { + observedTokens: 0, + savedTokens: 0, + calls: 0, + }; + + current.observedTokens += observedTokens; + current.savedTokens += savedTokens; + current.calls += 1; + session.byTool.set(tool, current); + session.observedTokens += observedTokens; + session.savedTokens += savedTokens; + + const entry: UsageEntry = { + ts: new Date().toISOString(), + sessionPid: process.pid, + workspaceId: input.workspaceId, + workspaceRoot: input.workspaceRoot, + workspaceName: input.workspaceRoot + ? String(input.workspaceRoot).split("/").filter(Boolean).at(-1) + : undefined, + tool, + path: input.path, + observedChars, + observedTokens, + savedChars, + savedTokens, + sessionObservedTokens: session.observedTokens, + sessionSavedTokens: session.savedTokens, + byTool: byToolSnapshot(), + note: "DevSpace observed text estimate only. Not ChatGPT actual model usage.", + }; + appendHistory(entry); + return entry; +} + +function fullUsageSummaryText(entry: UsageEntry): string { + const byTool = Object.entries(entry.byTool) + .map(([tool, value]) => `${tool} ${compactTokenCount(value.observedTokens)}`) + .join(" / "); + + return [ + "DevSpace token estimate:", + `Observed this call: ~${compactTokenCount(entry.observedTokens)} / session: ~${compactTokenCount(entry.sessionObservedTokens)}`, + `Session by tool: ${byTool || "none"}`, + `Estimated text avoided: ~${compactTokenCount(entry.sessionSavedTokens)} tokens`, + "Calculated only from text handled by DevSpace. This is not actual model usage or billing data.", + ].join("\n"); +} + +function compactUsageSummaryText(entry: UsageEntry): string { + return `DevSpace token estimate: call ~${compactTokenCount(entry.observedTokens)} / session ~${compactTokenCount(entry.sessionObservedTokens)}`; +} + +export function appendUsageToContent( + content: T[], + entry: UsageEntry, + mode: UsageContentMode, +): ToolContent[] { + if (mode === "off") return content; + const text = mode === "full" + ? fullUsageSummaryText(entry) + : compactUsageSummaryText(entry); + return [...content, { type: "text", text }]; +} diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 87f6b35a..d28d81b8 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -11,10 +11,9 @@ import { WorkspaceRegistry } from "./workspaces.js"; const execFileAsync = promisify(execFile); const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); +const agentDir = await mkdtemp(join(tmpdir(), "devspace-agent-dir-test-")); try { - const agentDir = join(root, ".pi", "agent"); - await mkdir(agentDir, { recursive: true }); await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n"); await writeFile(join(root, "AGENTS.md"), "root instructions\n"); await mkdir(join(root, ".devspace", "agents"), { recursive: true }); @@ -56,12 +55,22 @@ try { availableAgentsFiles.map((file) => file.path), [join(root, "nested", "AGENTS.md")], ); + const advertisedGlobalInstruction = registry.resolveReadPath( + workspace, + join(agentDir, "AGENTS.md"), + ); + assert.equal(advertisedGlobalInstruction.absolutePath, join(agentDir, "AGENTS.md")); + assert.throws( + () => registry.resolveReadPath(workspace, join(agentDir, "not-advertised.txt")), + /outside allowed roots/, + ); assert.deepEqual( workspace.agentProfiles.map((profile) => ({ name: profile.name, description: profile.description, provider: profile.provider, body: profile.body, + writeMode: profile.writeMode, })), [ { @@ -69,6 +78,7 @@ try { description: "Read-only project reviewer.", provider: "codex", body: "Review only.", + writeMode: "allowed", }, ], ); @@ -155,6 +165,7 @@ try { } } finally { await rm(root, { recursive: true, force: true }); + await rm(agentDir, { recursive: true, force: true }); } async function git(cwd: string, args: string[]): Promise { diff --git a/src/workspaces.ts b/src/workspaces.ts index 673d0823..5c43f1dd 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -5,7 +5,7 @@ import { dirname, join, relative, resolve, sep } from "node:path"; import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; import { createManagedWorktree } from "./git-worktrees.js"; -import { assertAllowedPath, isPathInsideRoot, resolveAllowedPath } from "./roots.js"; +import { assertAllowedPath, expandHomePath, isPathInsideRoot, resolveAllowedPath } from "./roots.js"; import { loadWorkspaceSkills, markSkillActivated, @@ -46,6 +46,7 @@ export interface Workspace { skillDiagnostics: LoadedSkills["diagnostics"]; agentProfiles: LocalAgentProfile[]; activatedSkillDirs: Set; + advertisedInstructionPaths: Set; } export interface WorkspaceContext { @@ -117,6 +118,9 @@ export class WorkspaceRegistry { ...this.loadSkillsForWorkspace(root), agentProfiles: [], activatedSkillDirs: new Set(), + advertisedInstructionPaths: new Set( + this.loadInitialAgentsFiles(root).map((file) => resolve(file.path)), + ), }; this.store?.touchSession(workspaceId); this.workspaces.set(restoredWorkspace.id, restoredWorkspace); @@ -140,6 +144,14 @@ export class WorkspaceRegistry { readRoots: [workspace.root], }; } catch (workspaceError) { + const advertisedInstructionPath = resolve(expandHomePath(inputPath)); + if (workspace.advertisedInstructionPaths.has(advertisedInstructionPath)) { + return { + absolutePath: advertisedInstructionPath, + readRoots: [dirname(advertisedInstructionPath)], + }; + } + const skillRead = resolveSkillReadPath( workspace.skills, workspace.activatedSkillDirs, @@ -208,6 +220,7 @@ export class WorkspaceRegistry { ...this.loadSkillsForWorkspace(input.root), agentProfiles: await loadLocalAgentProfiles(this.config, input.root), activatedSkillDirs: new Set(), + advertisedInstructionPaths: new Set(), }; this.store?.createSession({ @@ -222,6 +235,9 @@ export class WorkspaceRegistry { this.workspaces.set(workspace.id, workspace); const agentsFiles = this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); + workspace.advertisedInstructionPaths = new Set( + [...agentsFiles, ...availableAgentsFiles].map((file) => resolve(file.path)), + ); return { workspace, agentsFiles, availableAgentsFiles }; } From da02851baf7bee2e1e126008d201d1a1a8e9a59f Mon Sep 17 00:00:00 2001 From: uniplanck <198168437+uniplanck@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:59:12 +0900 Subject: [PATCH 2/4] feat: add runtime reliability and Finder integration --- docs/configuration.md | 36 +++- package.json | 3 +- src/pi-tools.ts | 3 +- src/register-v11-tools.ts | 185 ++++++++++++++++++- src/runtime-operations.test.ts | 45 +++++ src/runtime-operations.ts | 318 +++++++++++++++++++++++++++++++++ src/server.ts | 38 +++- src/shell-environment.test.ts | 46 +++++ src/shell-environment.ts | 89 +++++++++ src/ui/workspace-app.css | 53 ++++++ src/ui/workspace-app.tsx | 59 ++++++ src/usage-meter.test.ts | 21 ++- src/usage-meter.ts | 115 ++++++++++-- 13 files changed, 988 insertions(+), 23 deletions(-) create mode 100644 src/runtime-operations.test.ts create mode 100644 src/runtime-operations.ts create mode 100644 src/shell-environment.test.ts create mode 100644 src/shell-environment.ts diff --git a/docs/configuration.md b/docs/configuration.md index cd4bfa6c..b7265b69 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,16 +87,19 @@ sessions. | `changes` | Enables the aggregate `show_changes` tool and attaches widget UI to `open_workspace` and `show_changes`. | | `off` | Disables widget UI. This is the default in compact mode. | -## Compact payloads and usage estimates +## Compact payloads and execution-cost estimates | Variable | Default | Purpose | | --- | --- | --- | | `DEVSPACE_OPEN_WORKSPACE_PAYLOAD` | `compact` | Use `compact` for bounded instruction excerpts and smaller skill metadata, or `full` for the v1.0 payload. | | `DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS` | `6000` | Maximum characters returned per instruction excerpt; minimum `256`. | -| `DEVSPACE_USAGE_CONTENT` | `compact` | Append `compact` or `full` observed-token estimates to tool output, or use `off` to hide them. | -| `DEVSPACE_USAGE_HISTORY` | `~/.local/share/devspace/usage-history.jsonl` | Local JSONL destination for non-blocking usage diagnostics. | +| `DEVSPACE_USAGE_CONTENT` | `compact` | Append `compact` or `full` execution-cost summaries to tool output, or use `off` to hide them. | +| `DEVSPACE_USAGE_HISTORY` | `~/.local/share/devspace/usage-history.jsonl` | Local JSONL destination for non-blocking execution diagnostics. | -Token counts are estimates from text handled by DevSpace, not ChatGPT model billing or actual model usage. +Execution diagnostics record observed server duration, Tool call count, error count, retry count, +input/output character volume, and text-token estimates. Token counts are estimates from text handled +by DevSpace, not host-model billing or actual model usage. Use the `execution_costs` Tool for the +current server-process aggregate. ## DevSpace v1.1 feature flags @@ -113,6 +116,31 @@ All v1.1 feature flags default to `0`, so the existing compact Tool catalog and Feature flag values are strict: `1/0`, `true/false`, `yes/no`, and `on/off` are accepted. New Tool results include `serverDurationMs`, `payloadCharacters`, `returnedItems`, and `truncated`. +## Runtime reliability Tools + +The following guarded Tools are always available after `open_workspace`: + +| Tool | Purpose | +| --- | --- | +| `diagnose_runtime` | Classifies workspace access, Git detection, executable discovery, safe PATH fallbacks, and optional GitHub CLI authentication without returning credentials. | +| `compatibility_smoke_test` | Runs bounded read-only checks for list/read/search, shell PATH resolution, Git, and MCP App resources. | +| `execution_costs` | Returns observed duration, calls, errors, retries, character volume, and estimated text tokens. | +| `open_in_finder` | On macOS, opens a validated workspace directory or reveals a validated file in Finder. | + +Shell execution augments the inherited PATH with existing standard locations such as +`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, and system binary directories. It does not +source `.zshrc`, `.zprofile`, or another login-shell configuration, avoiding startup side effects. + +When Widgets are enabled, Tool cards with a workspace path display a link-style **Open in Finder** +action. The App calls `open_in_finder`; the server validates the path against the workspace before +invoking macOS Finder. Paths outside the approved workspace are rejected by the normal root guard. + +For a quick regression check after a host-model rollout, call `compatibility_smoke_test` or run: + +```bash +npm run test:runtime +``` + The v1.1 package intentionally ships Design Audit as an adapter only: no Playwright/CDP/axe runtime is currently bundled, no browser binary is downloaded, and an enabled Tool returns an unavailable error until a real adapter is connected. A future adapter must use an ephemeral diff --git a/package.json b/package.json index b6ad4b4f..f5b00223 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/usage-meter.test.ts && tsx src/pi-tools.test.ts && tsx src/skill-matcher.test.ts && tsx src/compound-tools.test.ts && tsx src/design-audit.test.ts && tsx src/register-v11-tools.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/shell-environment.test.ts && tsx src/runtime-operations.test.ts && tsx src/usage-meter.test.ts && tsx src/pi-tools.test.ts && tsx src/skill-matcher.test.ts && tsx src/compound-tools.test.ts && tsx src/design-audit.test.ts && tsx src/register-v11-tools.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test:runtime": "tsx src/shell-environment.test.ts && tsx src/runtime-operations.test.ts && tsx src/usage-meter.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/pi-tools.ts b/src/pi-tools.ts index dd72dd72..fb7e62fb 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -20,6 +20,7 @@ import { type AgentToolResult, } from "@earendil-works/pi-coding-agent"; import { expandHomePath, resolveAllowedPath } from "./roots.js"; +import { commandWithAugmentedPath } from "./shell-environment.js"; type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; export type ToolResponse = { @@ -214,7 +215,7 @@ export async function runShellTool(input: BashToolInput, context: ToolContext): : Math.min(approved.input.timeout, 300); return runTool((params) => tool.execute("run_shell", params), { - command: approved.input.command, + command: commandWithAugmentedPath(approved.input.command), timeout, }, context); } diff --git a/src/register-v11-tools.ts b/src/register-v11-tools.ts index 9730a8cf..a1116c35 100644 --- a/src/register-v11-tools.ts +++ b/src/register-v11-tools.ts @@ -11,6 +11,12 @@ import { import { runDesignAudit } from "./design-audit.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; import { matchWorkspaceSkills } from "./skill-matcher.js"; +import { + diagnoseRuntime, + openPathInFinder, + runCompatibilitySmoke, +} from "./runtime-operations.js"; +import { getExecutionCostSnapshot } from "./usage-meter.js"; import type { Workspace, WorkspaceRegistry } from "./workspaces.js"; const metricsSchema = z.object({ @@ -36,6 +42,7 @@ export function registerV11Tools( localAgentProviders: LocalAgentProviderAvailability[]; }, ): void { + registerRuntimeTools(server, input); const enabledTools = new Set(enabledV11ToolNames(input.config)); if (enabledTools.has("match_skills")) { registerAppTool( @@ -138,6 +145,180 @@ export function registerV11Tools( } } +function registerRuntimeTools( + server: McpServer, + input: { + config: ServerConfig; + workspaces: WorkspaceRegistry; + localAgentProviders: LocalAgentProviderAvailability[]; + }, +): void { + registerAppTool( + server, + "diagnose_runtime", + { + title: "Diagnose runtime", + description: "Diagnose workspace access, safe shell PATH fallbacks, executable discovery, Git state, and optional GitHub CLI authentication without returning credentials or token contents.", + inputSchema: { + workspaceId: z.string(), + commands: z.array(z.string().regex(/^[A-Za-z0-9._+-]+$/u)).max(20).optional(), + checkGitHubAuth: z.boolean().optional(), + }, + outputSchema: { + platform: z.string(), + nodeVersion: z.string(), + workspace: z.object({ + root: z.string(), + name: z.string(), + accessible: z.boolean(), + gitRepository: z.boolean(), + }), + shellPath: z.object({ + entryCount: z.number().int().nonnegative(), + addedEntries: z.array(z.string()), + }), + executables: z.array(z.object({ + command: z.string(), + found: z.boolean(), + path: z.string().optional(), + })), + githubAuthentication: z.enum(["not_checked", "authenticated", "unauthenticated", "gh_unavailable"]), + diagnostics: z.array(z.string()), + metrics: metricsSchema, + }, + _meta: {}, + annotations: readOnlyAnnotations, + }, + async ({ workspaceId, commands, checkGitHubAuth }) => { + const workspace = input.workspaces.getWorkspace(workspaceId); + const result = await diagnoseRuntime({ + workspaceRoot: workspace.root, + commands, + checkGitHubAuth, + }); + return toolResult( + result, + `Runtime diagnosis: ${result.executables.filter((entry) => entry.found).length}/${result.executables.length} executables available; GitHub auth ${result.githubAuthentication}.`, + ); + }, + ); + + registerAppTool( + server, + "compatibility_smoke_test", + { + title: "Compatibility smoke test", + description: "Run a bounded read-only compatibility smoke test for workspace access, listing, reading, text search, augmented shell PATH, Git, and MCP App resources.", + inputSchema: { + workspaceId: z.string(), + }, + outputSchema: { + status: z.enum(["passed", "failed"]), + steps: z.array(z.object({ + name: z.string(), + status: z.enum(["passed", "failed", "skipped"]), + detail: z.string(), + durationMs: z.number().int().nonnegative(), + })), + summary: z.object({ + passed: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), + }), + metrics: metricsSchema, + }, + _meta: {}, + annotations: readOnlyAnnotations, + }, + async ({ workspaceId }) => { + const workspace = input.workspaces.getWorkspace(workspaceId); + const result = await runCompatibilitySmoke(workspace.root); + return { + ...toolResult( + result, + `Compatibility smoke test ${result.status}: ${result.summary.passed} passed, ${result.summary.failed} failed, ${result.summary.skipped} skipped.`, + ), + isError: result.status === "failed", + }; + }, + ); + + registerAppTool( + server, + "execution_costs", + { + title: "Execution costs", + description: "Return observed DevSpace execution duration, tool calls, errors, retries, character volume, and estimated text tokens for the current server process.", + inputSchema: {}, + outputSchema: { + observedTokens: z.number().int().nonnegative(), + savedTokens: z.number().int().nonnegative(), + totalDurationMs: z.number().int().nonnegative(), + calls: z.number().int().nonnegative(), + errors: z.number().int().nonnegative(), + retries: z.number().int().nonnegative(), + byTool: z.record(z.string(), z.object({ + observedTokens: z.number().int().nonnegative(), + savedTokens: z.number().int().nonnegative(), + calls: z.number().int().nonnegative(), + inputChars: z.number().int().nonnegative(), + outputChars: z.number().int().nonnegative(), + payloadChars: z.number().int().nonnegative(), + totalDurationMs: z.number().int().nonnegative(), + errorCalls: z.number().int().nonnegative(), + retries: z.number().int().nonnegative(), + })), + note: z.string(), + }, + _meta: {}, + annotations: readOnlyAnnotations, + }, + async () => { + const result = getExecutionCostSnapshot(); + return toolResult( + result, + `Execution costs: ${result.calls} calls, ${result.totalDurationMs}ms, ${result.errors} errors, ~${result.observedTokens} observed tokens.`, + ); + }, + ); + + registerAppTool( + server, + "open_in_finder", + { + title: "Open in Finder", + description: "Open a workspace-scoped local path in macOS Finder after validating that it remains inside the open workspace. Files are revealed; directories are opened. This is intended for an explicit user click or request.", + inputSchema: { + workspaceId: z.string(), + path: z.string().min(1).max(4_000), + }, + outputSchema: { + status: z.enum(["opened", "unsupported"]), + path: z.string(), + kind: z.enum(["file", "directory"]), + }, + _meta: {}, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ workspaceId, path }) => { + const workspace = input.workspaces.getWorkspace(workspaceId); + const absolutePath = input.workspaces.resolvePath(workspace, path); + const result = await openPathInFinder(absolutePath); + return toolResult( + result, + result.status === "opened" + ? `Opened ${result.kind} in Finder.` + : "Finder integration is only available on macOS.", + ); + }, + ); +} + export function enabledV11ToolNames(config: ServerConfig): string[] { return [ config.skillMatcher && config.skillsEnabled ? "match_skills" : undefined, @@ -328,9 +509,9 @@ function inspectionContext( }; } -function toolResult>(structuredContent: T, summary: string) { +function toolResult(structuredContent: T, summary: string) { return { content: [{ type: "text" as const, text: summary.slice(0, 1_000) }], - structuredContent, + structuredContent: structuredContent as Record, }; } diff --git a/src/runtime-operations.test.ts b/src/runtime-operations.test.ts new file mode 100644 index 00000000..ba2b2fda --- /dev/null +++ b/src/runtime-operations.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { + diagnoseRuntime, + finderOpenArguments, + runCompatibilitySmoke, +} from "./runtime-operations.js"; + +const execFileAsync = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "devspace-runtime-operations-")); +try { + await writeFile(join(root, "AGENTS.md"), "Test instructions.\n", "utf8"); + await writeFile(join(root, "package.json"), '{"name":"runtime-test"}\n', "utf8"); + await execFileAsync("git", ["init", "-q"], { cwd: root }); + + const diagnostics = await diagnoseRuntime({ + workspaceRoot: root, + commands: ["git", "node", "definitely-missing-command"], + checkGitHubAuth: false, + }); + assert.equal(diagnostics.workspace.accessible, true); + assert.equal(diagnostics.workspace.gitRepository, true); + assert.equal(diagnostics.executables.find((entry) => entry.command === "git")?.found, true); + assert.equal( + diagnostics.executables.find((entry) => entry.command === "definitely-missing-command")?.found, + false, + ); + assert.equal(diagnostics.githubAuthentication, "not_checked"); + + const smoke = await runCompatibilitySmoke(root); + assert.equal(smoke.status, "passed"); + assert.equal(smoke.summary.failed, 0); + assert.equal(smoke.steps.find((step) => step.name === "workspace")?.status, "passed"); + assert.equal(smoke.steps.find((step) => step.name === "git")?.status, "passed"); + + assert.deepEqual(finderOpenArguments("/tmp/file.txt", "file", "darwin"), ["-R", "/tmp/file.txt"]); + assert.deepEqual(finderOpenArguments("/tmp/folder", "directory", "darwin"), ["/tmp/folder"]); + assert.equal(finderOpenArguments("C:\\temp", "directory", "win32"), undefined); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/runtime-operations.ts b/src/runtime-operations.ts new file mode 100644 index 00000000..d1d7a59e --- /dev/null +++ b/src/runtime-operations.ts @@ -0,0 +1,318 @@ +import { execFile } from "node:child_process"; +import { access, readdir, readFile, stat } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { promisify } from "node:util"; +import { measuredPayload } from "./tool-metrics.js"; +import { resolveExecutable, shellPathInfo } from "./shell-environment.js"; + +const execFileAsync = promisify(execFile); + +export interface RuntimeDiagnosticResult { + platform: NodeJS.Platform; + nodeVersion: string; + workspace: { + root: string; + name: string; + accessible: boolean; + gitRepository: boolean; + }; + shellPath: { + entryCount: number; + addedEntries: string[]; + }; + executables: Array<{ + command: string; + found: boolean; + path?: string; + }>; + githubAuthentication: "not_checked" | "authenticated" | "unauthenticated" | "gh_unavailable"; + diagnostics: string[]; + metrics: { + serverDurationMs: number; + payloadCharacters: number; + returnedItems: number; + truncated: boolean; + }; +} + +export async function diagnoseRuntime(input: { + workspaceRoot: string; + commands?: string[]; + checkGitHubAuth?: boolean; +}): Promise { + const startedAt = performance.now(); + const commands = Array.from(new Set(input.commands ?? ["git", "node", "npm", "gh"])) + .filter((command) => /^[A-Za-z0-9._+-]+$/.test(command)) + .slice(0, 20); + const shell = shellPathInfo(); + const executables = commands.map((command) => { + const path = resolveExecutable(command); + return { command, found: Boolean(path), ...(path ? { path } : {}) }; + }); + const accessible = await pathAccessible(input.workspaceRoot); + const gitRepository = accessible && await isGitRepository(input.workspaceRoot); + const ghPath = executables.find((entry) => entry.command === "gh")?.path; + let githubAuthentication: RuntimeDiagnosticResult["githubAuthentication"] = "not_checked"; + if (input.checkGitHubAuth) { + if (!ghPath) { + githubAuthentication = "gh_unavailable"; + } else { + githubAuthentication = await commandSucceeds( + ghPath, + ["auth", "status", "--hostname", "github.com"], + input.workspaceRoot, + ) ? "authenticated" : "unauthenticated"; + } + } + + const diagnostics = [ + accessible ? "Workspace is accessible." : "Workspace is not accessible.", + gitRepository ? "Git repository detected." : "Git repository not detected.", + shell.addedEntries.length > 0 + ? `Added ${shell.addedEntries.length} safe PATH fallback(s).` + : "No PATH fallbacks were needed.", + ...executables + .filter((entry) => !entry.found) + .map((entry) => `Executable not found: ${entry.command}`), + input.checkGitHubAuth + ? `GitHub authentication: ${githubAuthentication}.` + : "GitHub authentication was not checked.", + ]; + + return measuredPayload({ + platform: process.platform, + nodeVersion: process.version, + workspace: { + root: input.workspaceRoot, + name: basename(input.workspaceRoot), + accessible, + gitRepository, + }, + shellPath: { + entryCount: shell.entries.length, + addedEntries: shell.addedEntries, + }, + executables, + githubAuthentication, + diagnostics, + }, { + startedAt, + returnedItems: executables.length + diagnostics.length, + truncated: false, + }); +} + +export interface CompatibilitySmokeResult { + status: "passed" | "failed"; + steps: Array<{ + name: string; + status: "passed" | "failed" | "skipped"; + detail: string; + durationMs: number; + }>; + summary: { + passed: number; + failed: number; + skipped: number; + }; + metrics: { + serverDurationMs: number; + payloadCharacters: number; + returnedItems: number; + truncated: boolean; + }; +} + +export async function runCompatibilitySmoke( + workspaceRoot: string, +): Promise { + const startedAt = performance.now(); + const steps: CompatibilitySmokeResult["steps"] = []; + + await smokeStep(steps, "workspace", async () => { + const info = await stat(workspaceRoot); + if (!info.isDirectory()) throw new Error("Workspace root is not a directory."); + return "Workspace directory is accessible."; + }); + + await smokeStep(steps, "list", async () => { + const entries = await readdir(workspaceRoot); + return `Listed ${entries.length} root item(s).`; + }); + + const readableCandidate = await firstAccessible([ + join(workspaceRoot, "AGENTS.md"), + join(workspaceRoot, "package.json"), + join(workspaceRoot, "README.md"), + ]); + if (readableCandidate) { + await smokeStep(steps, "read", async () => { + const content = await readFile(readableCandidate, "utf8"); + if (content.length === 0) throw new Error("Selected text file is empty."); + return `Read ${basename(readableCandidate)} (${content.length} characters).`; + }); + await smokeStep(steps, "search", async () => { + const content = await readFile(readableCandidate, "utf8"); + const lines = content.split(/\r?\n/u).filter((line) => /\S/u.test(line)); + if (lines.length === 0) throw new Error("No searchable text was found."); + return `Found ${lines.length} non-empty line(s).`; + }); + } else { + steps.push({ + name: "read", + status: "skipped", + detail: "No standard text file was available.", + durationMs: 0, + }); + steps.push({ + name: "search", + status: "skipped", + detail: "No standard text file was available.", + durationMs: 0, + }); + } + + await smokeStep(steps, "shell-path", async () => { + const executable = resolveExecutable("node"); + if (!executable) throw new Error("Node executable was not resolved from the augmented PATH."); + const { stdout } = await execFileAsync(executable, ["-e", "process.stdout.write(process.cwd())"], { + cwd: workspaceRoot, + timeout: 5_000, + maxBuffer: 8_192, + }); + if (!stdout.trim()) throw new Error("Shell probe returned no working directory."); + return "Resolved and executed Node through the augmented PATH."; + }); + + const gitPath = resolveExecutable("git"); + if (gitPath && await isGitRepository(workspaceRoot)) { + await smokeStep(steps, "git", async () => { + await execFileAsync(gitPath, ["status", "--short", "--branch"], { + cwd: workspaceRoot, + timeout: 5_000, + maxBuffer: 64_000, + }); + return "Git status completed."; + }); + } else { + steps.push({ + name: "git", + status: "skipped", + detail: gitPath ? "Workspace is not a Git repository." : "Git executable was not found.", + durationMs: 0, + }); + } + + const appResource = join(workspaceRoot, "src", "ui", "workspace-app.html"); + steps.push({ + name: "app-resource", + status: await pathAccessible(appResource) ? "passed" : "skipped", + detail: await pathAccessible(appResource) + ? "MCP App resource entry point is present." + : "No MCP App resource entry point was detected in this workspace.", + durationMs: 0, + }); + + const summary = { + passed: steps.filter((step) => step.status === "passed").length, + failed: steps.filter((step) => step.status === "failed").length, + skipped: steps.filter((step) => step.status === "skipped").length, + }; + + return measuredPayload({ + status: summary.failed === 0 ? "passed" : "failed", + steps, + summary, + }, { + startedAt, + returnedItems: steps.length, + truncated: false, + }); +} + +export function finderOpenArguments( + path: string, + kind: "file" | "directory", + platform: NodeJS.Platform = process.platform, +): string[] | undefined { + if (platform !== "darwin") return undefined; + return kind === "directory" ? [path] : ["-R", path]; +} + +export async function openPathInFinder(path: string): Promise<{ + status: "opened" | "unsupported"; + path: string; + kind: "file" | "directory"; +}> { + const info = await stat(path); + const kind = info.isDirectory() ? "directory" : "file"; + const args = finderOpenArguments(path, kind); + if (!args) { + return { status: "unsupported", path, kind }; + } + const openExecutable = resolveExecutable("open") ?? "/usr/bin/open"; + await execFileAsync(openExecutable, args, { + timeout: 10_000, + maxBuffer: 8_192, + }); + return { status: "opened", path, kind }; +} + +async function smokeStep( + steps: CompatibilitySmokeResult["steps"], + name: string, + operation: () => Promise, +): Promise { + const startedAt = performance.now(); + try { + const detail = await operation(); + steps.push({ + name, + status: "passed", + detail, + durationMs: Math.max(0, Math.round(performance.now() - startedAt)), + }); + } catch (error) { + steps.push({ + name, + status: "failed", + detail: error instanceof Error ? error.message : String(error), + durationMs: Math.max(0, Math.round(performance.now() - startedAt)), + }); + } +} + +async function pathAccessible(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function firstAccessible(paths: string[]): Promise { + for (const path of paths) { + if (await pathAccessible(path)) return path; + } + return undefined; +} + +async function isGitRepository(cwd: string): Promise { + const gitPath = resolveExecutable("git"); + if (!gitPath) return false; + return commandSucceeds(gitPath, ["rev-parse", "--is-inside-work-tree"], cwd); +} + +async function commandSucceeds(command: string, args: string[], cwd: string): Promise { + try { + await execFileAsync(command, args, { + cwd, + timeout: 5_000, + maxBuffer: 8_192, + }); + return true; + } catch { + return false; + } +} diff --git a/src/server.ts b/src/server.ts index 93edc39c..2100d937 100644 --- a/src/server.ts +++ b/src/server.ts @@ -336,10 +336,22 @@ function logFailedToolResponse( content: ToolContent[], startedAt: number, ): void { + const durationMs = Math.round(performance.now() - startedAt); + const outputChars = textContentChars(content); + recordObservedToolUsage({ + tool: fields.tool, + workspaceId: fields.workspaceId, + path: fields.path, + observedChars: outputChars, + savedChars: 0, + outputChars, + durationMs, + error: true, + }); logToolCall(config, { ...fields, success: false, - durationMs: Math.round(performance.now() - startedAt), + durationMs, error: toolErrorPreview(content), }); } @@ -1067,6 +1079,9 @@ function createMcpServer( path: input.path, observedChars, savedChars, + inputChars: String(input.path).length, + outputChars: observedChars, + durationMs: Math.round(performance.now() - startedAt), }); const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { @@ -1149,6 +1164,9 @@ function createMcpServer( path: input.path, observedChars: input.content.length + textContentChars(response.content), savedChars: 0, + inputChars: input.content.length, + outputChars: textContentChars(response.content), + durationMs: Math.round(performance.now() - startedAt), }); const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { @@ -1250,6 +1268,9 @@ function createMcpServer( path: input.path, observedChars, savedChars, + inputChars: editInputChars(input.edits), + outputChars: textContentChars(editContent), + durationMs: Math.round(performance.now() - startedAt), }); const content = appendUsageToContent(editContent, usage, config.usageContent); logToolCall(config, { @@ -1472,6 +1493,12 @@ function createMcpServer( + String(input.include ?? "").length + textContentChars(response.content), savedChars: 0, + inputChars: + String(input.pattern ?? "").length + + String(input.path ?? "").length + + String(input.include ?? "").length, + outputChars: textContentChars(response.content), + durationMs: Math.round(performance.now() - startedAt), }); const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { @@ -1555,6 +1582,9 @@ function createMcpServer( + String(input.path ?? "").length + textContentChars(response.content), savedChars: 0, + inputChars: String(input.pattern ?? "").length + String(input.path ?? "").length, + outputChars: textContentChars(response.content), + durationMs: Math.round(performance.now() - startedAt), }); const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { @@ -1631,6 +1661,9 @@ function createMcpServer( path: input.path, observedChars: String(input.path ?? "").length + textContentChars(response.content), savedChars: 0, + inputChars: String(input.path ?? "").length, + outputChars: textContentChars(response.content), + durationMs: Math.round(performance.now() - startedAt), }); const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { @@ -1731,6 +1764,9 @@ function createMcpServer( path: workingDirectory ?? ".", observedChars: input.command.length + textContentChars(response.content), savedChars: 0, + inputChars: input.command.length, + outputChars: textContentChars(response.content), + durationMs: Math.round(performance.now() - startedAt), }); const content = appendUsageToContent(response.content, usage, config.usageContent); logToolCall(config, { diff --git a/src/shell-environment.test.ts b/src/shell-environment.test.ts new file mode 100644 index 00000000..601803b3 --- /dev/null +++ b/src/shell-environment.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { delimiter } from "node:path"; +import { + commandWithAugmentedPath, + resolveExecutable, + shellPathInfo, +} from "./shell-environment.js"; + +const existing = ["/usr/bin", "/bin"].join(delimiter); +const existingDirs = new Set([ + "/opt/homebrew/bin", + "/usr/local/bin", + "/Users/test/.local/bin", + "/usr/bin", + "/bin", +]); +const info = shellPathInfo( + { PATH: existing }, + "darwin", + "/Users/test", + (path) => existingDirs.has(path), +); +assert.deepEqual(info.entries, [ + "/usr/bin", + "/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + "/Users/test/.local/bin", +]); +assert.deepEqual(info.addedEntries, [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/Users/test/.local/bin", +]); +assert.match(commandWithAugmentedPath("gh auth status", { PATH: existing }, "darwin"), /^export PATH='/); +assert.equal(commandWithAugmentedPath("dir", { PATH: existing }, "win32"), "dir"); +assert.equal( + resolveExecutable( + "gh", + { PATH: existing }, + "darwin", + (path) => path === "/opt/homebrew/bin/gh" || existingDirs.has(path), + ), + "/opt/homebrew/bin/gh", +); +assert.equal(resolveExecutable("bad command", { PATH: existing }, "darwin"), undefined); diff --git a/src/shell-environment.ts b/src/shell-environment.ts new file mode 100644 index 00000000..2983d421 --- /dev/null +++ b/src/shell-environment.ts @@ -0,0 +1,89 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { delimiter, join } from "node:path"; + +export interface ShellPathInfo { + path: string; + entries: string[]; + addedEntries: string[]; +} + +function uniqueEntries(entries: string[]): string[] { + const seen = new Set(); + return entries.filter((entry) => { + const normalized = entry.trim(); + if (!normalized || seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); +} + +export function shellPathInfo( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + home: string = homedir(), + directoryExists: (path: string) => boolean = existsSync, +): ShellPathInfo { + const existingEntries = String(env.PATH ?? "") + .split(delimiter) + .filter(Boolean); + const candidates = platform === "win32" + ? [] + : [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + join(home, ".local", "bin"), + join(home, "bin"), + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]; + const existingSet = new Set(existingEntries); + const addedEntries = uniqueEntries(candidates) + .filter((entry) => !existingSet.has(entry) && directoryExists(entry)); + const entries = uniqueEntries([...existingEntries, ...addedEntries]); + + return { + path: entries.join(delimiter), + entries, + addedEntries, + }; +} + +function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export function commandWithAugmentedPath( + command: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string { + if (platform === "win32") return command; + const info = shellPathInfo(env, platform); + return `export PATH=${quotePosix(info.path)}; ${command}`; +} + +export function resolveExecutable( + command: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + fileExists: (path: string) => boolean = existsSync, +): string | undefined { + if (!/^[A-Za-z0-9._+-]+$/.test(command)) return undefined; + const info = shellPathInfo(env, platform); + const extensions = platform === "win32" + ? String(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") + : [""]; + + for (const entry of info.entries) { + for (const extension of extensions) { + const candidate = join(entry, `${command}${extension}`); + if (fileExists(candidate)) return candidate; + } + } + return undefined; +} diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 28ba6bf5..fa8ecce3 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -105,6 +105,59 @@ body { white-space: nowrap; } +.path-action-row { + padding: 0 14px 10px 64px; +} + +.path-link { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + width: 100%; + min-height: 31px; + padding: 5px 8px 5px 10px; + border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 72%, transparent); + border-radius: 7px; + background: color-mix(in srgb, var(--color-background-primary, #17181c) 38%, transparent); + color: var(--color-text-secondary, #d6d6dc); + cursor: pointer; + text-align: left; +} + +.path-link:hover:not(:disabled), +.path-link:focus-visible { + border-color: color-mix(in srgb, var(--color-link, #78a9ff) 58%, var(--color-border-primary, #3a3a40)); + color: var(--color-link, #78a9ff); + outline: none; +} + +.path-link:disabled { + cursor: default; + opacity: 0.78; +} + +.path-link-text { + overflow: hidden; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: var(--font-text-xs-size, 12px); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); + text-overflow: ellipsis; + white-space: nowrap; +} + +.path-link-action { + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-xs-size, 12px); + white-space: nowrap; +} + +.path-link:hover:not(:disabled) .path-link-action, +.path-link:focus-visible .path-link-action { + color: inherit; +} + .stats { display: inline-flex; gap: 5px; diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index edbb620f..d558621e 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -200,6 +200,8 @@ function render(): void { renderChevron(expanded, expandable), ); section.append(button); + const pathAction = renderPathAction(card); + if (pathAction) section.append(pathAction); if (expanded) { const body = element("div", { className: "tool-body" }); @@ -212,6 +214,63 @@ function render(): void { renderPayloadIfNeeded(); } +function renderPathAction(card: ToolResultCard): HTMLElement | null { + const displayPath = card.path ?? card.root; + const toolPath = card.path ?? (card.root ? "." : undefined); + if (!app || !card.workspaceId || !displayPath || !toolPath) return null; + + const row = element("div", { className: "path-action-row" }); + const button = element("button", { + className: "path-link", + type: "button", + title: `Open in Finder: ${displayPath}`, + }); + button.setAttribute("aria-label", `Open in Finder: ${displayPath}`); + const pathText = element("span", { + className: "path-link-text", + text: displayPath, + }); + const actionText = element("span", { + className: "path-link-action", + text: "Open in Finder", + }); + button.append(pathText, actionText); + + button.addEventListener("click", async () => { + if (!app || button.disabled) return; + button.disabled = true; + actionText.textContent = "Opening…"; + try { + const result = await app.callServerTool({ + name: "open_in_finder", + arguments: { + workspaceId: card.workspaceId, + path: toolPath, + }, + }); + const structured = getStructuredContent<{ status?: string }>(result); + if (result.isError || structured?.status === "unsupported") { + actionText.textContent = structured?.status === "unsupported" + ? "macOS only" + : "Unable to open"; + button.disabled = false; + return; + } + actionText.textContent = "Opened in Finder"; + window.setTimeout(() => { + actionText.textContent = "Open in Finder"; + button.disabled = false; + }, 1_800); + } catch (openError) { + actionText.textContent = openError instanceof Error ? "Unable to open" : "Error"; + button.disabled = false; + } + }); + + row.append(button); + return row; +} + function renderEmpty(message: string, tone: "muted" | "error" = "muted"): void { const main = element("main", { className: "shell" }); main.append(element("section", { className: `empty ${tone}`, text: message })); diff --git a/src/usage-meter.test.ts b/src/usage-meter.test.ts index 448dc8e1..f18fec43 100644 --- a/src/usage-meter.test.ts +++ b/src/usage-meter.test.ts @@ -2,9 +2,11 @@ import assert from "node:assert/strict"; import { platform } from "node:os"; import { appendUsageToContent, + compactDuration, compactTokenCount, editInputChars, estimateTokensFromChars, + getExecutionCostSnapshot, recordObservedToolUsage, textContentChars, } from "./usage-meter.js"; @@ -13,6 +15,8 @@ assert.equal(estimateTokensFromChars(0), 0); assert.equal(estimateTokensFromChars(5), 2); assert.equal(compactTokenCount(999), "999"); assert.equal(compactTokenCount(1_500), "1.5k"); +assert.equal(compactDuration(950), "950ms"); +assert.equal(compactDuration(1_500), "1.5s"); assert.equal(editInputChars([{ oldText: "old", newText: "new" }]), 6); assert.equal(textContentChars([{ type: "text", text: "hello" }]), 5); @@ -22,22 +26,35 @@ const usage = recordObservedToolUsage({ tool: "read", observedChars: 40, savedChars: 80, + inputChars: 10, + outputChars: 30, + payloadChars: 40, + durationMs: 125, + error: false, + retries: 1, }); const content = [{ type: "text" as const, text: "result" }]; +assert.equal(usage.durationMs, 125); +assert.equal(usage.sessionCalls >= 1, true); assert.deepEqual(appendUsageToContent(content, usage, "off"), content); const compactContent = appendUsageToContent(content, usage, "compact"); const compactText = compactContent.at(-1); assert.match( compactText?.type === "text" ? compactText.text : "", - /DevSpace token estimate/, + /DevSpace cost:/, ); const fullContent = appendUsageToContent(content, usage, "full"); const fullText = fullContent.at(-1); assert.match( fullText?.type === "text" ? fullText.text : "", - /not actual model usage or billing data/, + /Execution cost estimate/, ); +const snapshot = getExecutionCostSnapshot(); +assert.equal(snapshot.calls >= 1, true); +assert.equal(snapshot.byTool.read.calls >= 1, true); +assert.equal(snapshot.byTool.read.totalDurationMs >= 125, true); +assert.equal(snapshot.retries >= 1, true); if (previousHistory === undefined) { delete process.env.DEVSPACE_USAGE_HISTORY; diff --git a/src/usage-meter.ts b/src/usage-meter.ts index 83f6b84d..ec6401c6 100644 --- a/src/usage-meter.ts +++ b/src/usage-meter.ts @@ -7,10 +7,16 @@ import type { UsageContentMode } from "./config.js"; type TextContent = { type: "text"; text: string }; type ToolContent = TextContent | { type: "image"; data: string; mimeType: string }; -interface UsageBucket { +export interface UsageBucket { observedTokens: number; savedTokens: number; calls: number; + inputChars: number; + outputChars: number; + payloadChars: number; + totalDurationMs: number; + errorCalls: number; + retries: number; } export interface UsageEntry { @@ -25,8 +31,29 @@ export interface UsageEntry { observedTokens: number; savedChars: number; savedTokens: number; + inputChars: number; + outputChars: number; + payloadChars: number; + durationMs: number; + error: boolean; + retries: number; sessionObservedTokens: number; sessionSavedTokens: number; + sessionDurationMs: number; + sessionCalls: number; + sessionErrors: number; + sessionRetries: number; + byTool: Record; + note: string; +} + +export interface ExecutionCostSnapshot { + observedTokens: number; + savedTokens: number; + totalDurationMs: number; + calls: number; + errors: number; + retries: number; byTool: Record; note: string; } @@ -34,6 +61,10 @@ export interface UsageEntry { const session = { observedTokens: 0, savedTokens: 0, + totalDurationMs: 0, + calls: 0, + errors: 0, + retries: 0, byTool: new Map(), }; let historyWrite = Promise.resolve(); @@ -43,8 +74,8 @@ function historyPath(): string { ?? join(homedir(), ".local", "share", "devspace", "usage-history.jsonl"); } -function clampNumber(value: number): number { - return Number.isFinite(value) && value > 0 ? Math.ceil(value) : 0; +function clampNumber(value: number | undefined): number { + return Number.isFinite(value) && Number(value) > 0 ? Math.ceil(Number(value)) : 0; } export function estimateTokensFromChars(chars: number): number { @@ -80,9 +111,17 @@ export function compactTokenCount(tokens: number): string { return `${tokens}`; } +export function compactDuration(durationMs: number): string { + if (durationMs >= 60_000) return `${(durationMs / 60_000).toFixed(1)}m`; + if (durationMs >= 1_000) return `${(durationMs / 1_000).toFixed(1)}s`; + return `${durationMs}ms`; +} + function byToolSnapshot(): Record { return Object.fromEntries( - Array.from(session.byTool.entries()).sort(([a], [b]) => a.localeCompare(b)), + Array.from(session.byTool.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([tool, bucket]) => [tool, { ...bucket }]), ); } @@ -103,24 +142,52 @@ export function recordObservedToolUsage(input: { path?: string; observedChars: number; savedChars: number; + inputChars?: number; + outputChars?: number; + payloadChars?: number; + durationMs?: number; + error?: boolean; + retries?: number; }): UsageEntry { const tool = input.tool ?? "unknown"; const observedChars = clampNumber(input.observedChars); const savedChars = clampNumber(input.savedChars); const observedTokens = estimateTokensFromChars(observedChars); const savedTokens = estimateTokensFromChars(savedChars); + const inputChars = clampNumber(input.inputChars); + const outputChars = clampNumber(input.outputChars); + const payloadChars = clampNumber(input.payloadChars ?? observedChars); + const durationMs = clampNumber(input.durationMs); + const retries = clampNumber(input.retries); + const error = input.error === true; const current = session.byTool.get(tool) ?? { observedTokens: 0, savedTokens: 0, calls: 0, + inputChars: 0, + outputChars: 0, + payloadChars: 0, + totalDurationMs: 0, + errorCalls: 0, + retries: 0, }; current.observedTokens += observedTokens; current.savedTokens += savedTokens; current.calls += 1; + current.inputChars += inputChars; + current.outputChars += outputChars; + current.payloadChars += payloadChars; + current.totalDurationMs += durationMs; + current.errorCalls += error ? 1 : 0; + current.retries += retries; session.byTool.set(tool, current); session.observedTokens += observedTokens; session.savedTokens += savedTokens; + session.totalDurationMs += durationMs; + session.calls += 1; + session.errors += error ? 1 : 0; + session.retries += retries; const entry: UsageEntry = { ts: new Date().toISOString(), @@ -136,31 +203,55 @@ export function recordObservedToolUsage(input: { observedTokens, savedChars, savedTokens, + inputChars, + outputChars, + payloadChars, + durationMs, + error, + retries, sessionObservedTokens: session.observedTokens, sessionSavedTokens: session.savedTokens, + sessionDurationMs: session.totalDurationMs, + sessionCalls: session.calls, + sessionErrors: session.errors, + sessionRetries: session.retries, byTool: byToolSnapshot(), - note: "DevSpace observed text estimate only. Not ChatGPT actual model usage.", + note: "DevSpace observed execution estimate only. Not host-model billing or actual model usage.", }; appendHistory(entry); return entry; } +export function getExecutionCostSnapshot(): ExecutionCostSnapshot { + return { + observedTokens: session.observedTokens, + savedTokens: session.savedTokens, + totalDurationMs: session.totalDurationMs, + calls: session.calls, + errors: session.errors, + retries: session.retries, + byTool: byToolSnapshot(), + note: "Text-token values are estimates; duration and call/error counts are observed by DevSpace.", + }; +} + function fullUsageSummaryText(entry: UsageEntry): string { const byTool = Object.entries(entry.byTool) - .map(([tool, value]) => `${tool} ${compactTokenCount(value.observedTokens)}`) + .map(([tool, value]) => `${tool} ${value.calls} calls/${compactDuration(value.totalDurationMs)}`) .join(" / "); return [ - "DevSpace token estimate:", - `Observed this call: ~${compactTokenCount(entry.observedTokens)} / session: ~${compactTokenCount(entry.sessionObservedTokens)}`, - `Session by tool: ${byTool || "none"}`, - `Estimated text avoided: ~${compactTokenCount(entry.sessionSavedTokens)} tokens`, - "Calculated only from text handled by DevSpace. This is not actual model usage or billing data.", + "Execution cost estimate:", + `Current: ~${compactTokenCount(entry.observedTokens)} tokens / ${compactDuration(entry.durationMs)} / ${entry.error ? "error" : "ok"}`, + `Session: ~${compactTokenCount(entry.sessionObservedTokens)} tokens / ${compactDuration(entry.sessionDurationMs)} / ${entry.sessionCalls} calls / ${entry.sessionErrors} errors / ${entry.sessionRetries} retries`, + `By tool: ${byTool || "none"}`, + `Estimated saved tokens: ~${compactTokenCount(entry.sessionSavedTokens)}`, + "Token values are estimated from text handled by DevSpace, not actual host-model usage.", ].join("\n"); } function compactUsageSummaryText(entry: UsageEntry): string { - return `DevSpace token estimate: call ~${compactTokenCount(entry.observedTokens)} / session ~${compactTokenCount(entry.sessionObservedTokens)}`; + return `DevSpace cost: ~${compactTokenCount(entry.observedTokens)} tok · ${compactDuration(entry.durationMs)} | session ~${compactTokenCount(entry.sessionObservedTokens)} tok · ${entry.sessionCalls} calls · ${entry.sessionErrors} errors`; } export function appendUsageToContent( From 945b9e873771859d01f9b44f9a8c38bedef96967 Mon Sep 17 00:00:00 2001 From: uniplanck <198168437+uniplanck@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:29:10 +0900 Subject: [PATCH 3/4] fix: integrate runtime features into existing tools --- docs/configuration.md | 26 +++---- src/pi-tools.test.ts | 33 +++++++++ src/pi-tools.ts | 103 ++++++++++++++++++++++++++ src/register-v11-tools.ts | 148 +++----------------------------------- src/server.ts | 6 +- 5 files changed, 162 insertions(+), 154 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b7265b69..113593e7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -98,8 +98,8 @@ sessions. Execution diagnostics record observed server duration, Tool call count, error count, retry count, input/output character volume, and text-token estimates. Token counts are estimates from text handled -by DevSpace, not host-model billing or actual model usage. Use the `execution_costs` Tool for the -current server-process aggregate. +by DevSpace, not host-model billing or actual model usage. Run `devspace-runtime costs` through +the existing Bash Tool for the current server-process aggregate. ## DevSpace v1.1 feature flags @@ -116,26 +116,28 @@ All v1.1 feature flags default to `0`, so the existing compact Tool catalog and Feature flag values are strict: `1/0`, `true/false`, `yes/no`, and `on/off` are accepted. New Tool results include `serverDurationMs`, `payloadCharacters`, `returnedItems`, and `truncated`. -## Runtime reliability Tools +## Runtime reliability commands -The following guarded Tools are always available after `open_workspace`: +Runtime diagnostics are integrated into the existing Bash Tool instead of increasing the model-facing +MCP Tool catalog. These exact commands are intercepted by DevSpace and do not start a login shell: -| Tool | Purpose | +| Bash command | Purpose | | --- | --- | -| `diagnose_runtime` | Classifies workspace access, Git detection, executable discovery, safe PATH fallbacks, and optional GitHub CLI authentication without returning credentials. | -| `compatibility_smoke_test` | Runs bounded read-only checks for list/read/search, shell PATH resolution, Git, and MCP App resources. | -| `execution_costs` | Returns observed duration, calls, errors, retries, character volume, and estimated text tokens. | -| `open_in_finder` | On macOS, opens a validated workspace directory or reveals a validated file in Finder. | +| `devspace-runtime diagnose [--github] [command ...]` | Classifies workspace access, Git detection, executable discovery, safe PATH fallbacks, and optional GitHub CLI authentication without returning credentials. | +| `devspace-runtime smoke` | Runs bounded read-only checks for list/read/search, shell PATH resolution, Git, and MCP App resources. | +| `devspace-runtime costs` | Returns observed duration, calls, errors, retries, character volume, and estimated text tokens from the current server process. | +| `devspace-runtime finder ` | On macOS, opens a validated workspace directory or reveals a validated file in Finder after an explicit request. | Shell execution augments the inherited PATH with existing standard locations such as `/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, and system binary directories. It does not source `.zshrc`, `.zprofile`, or another login-shell configuration, avoiding startup side effects. When Widgets are enabled, Tool cards with a workspace path display a link-style **Open in Finder** -action. The App calls `open_in_finder`; the server validates the path against the workspace before -invoking macOS Finder. Paths outside the approved workspace are rejected by the normal root guard. +action. The App calls the app-only `open_in_finder` Tool; it is hidden from the model-facing catalog. +The server validates the path against the workspace before invoking macOS Finder. Paths outside the +approved workspace are rejected by the normal root guard. -For a quick regression check after a host-model rollout, call `compatibility_smoke_test` or run: +For a quick regression check after a host-model rollout, run `devspace-runtime smoke` through Bash or: ```bash npm run test:runtime diff --git a/src/pi-tools.test.ts b/src/pi-tools.test.ts index 55f1a857..b606a669 100644 --- a/src/pi-tools.test.ts +++ b/src/pi-tools.test.ts @@ -27,6 +27,39 @@ try { assert.equal(allowed.isError, undefined); assert.match(allowed.content[0]?.type === "text" ? allowed.content[0].text : "", /nested/); + const diagnosis = await runShellTool( + { command: "devspace-runtime diagnose node git" }, + { cwd: root, root }, + ); + assert.equal(diagnosis.isError, undefined); + const diagnosisText = diagnosis.content[0]?.type === "text" ? diagnosis.content[0].text : ""; + assert.match(diagnosisText, /"accessible": true/); + assert.match(diagnosisText, /"command": "node"/); + + const smoke = await runShellTool( + { command: "devspace-runtime smoke" }, + { cwd: root, root }, + ); + assert.equal(smoke.isError, undefined); + assert.match(smoke.content[0]?.type === "text" ? smoke.content[0].text : "", /"status": "passed"/); + + const costs = await runShellTool( + { command: "devspace-runtime costs" }, + { cwd: root, root }, + ); + assert.equal(costs.isError, undefined); + assert.match(costs.content[0]?.type === "text" ? costs.content[0].text : "", /"calls":/); + + const finderEscape = await runShellTool( + { command: "devspace-runtime finder ../outside" }, + { cwd: root, root }, + ); + assert.equal(finderEscape.isError, true); + assert.match( + finderEscape.content[0]?.type === "text" ? finderEscape.content[0].text : "", + /outside allowed roots/, + ); + await writeFile(commandsFile, JSON.stringify({ commands: [{ alias: "escape", diff --git a/src/pi-tools.ts b/src/pi-tools.ts index fb7e62fb..f01be365 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -20,7 +20,13 @@ import { type AgentToolResult, } from "@earendil-works/pi-coding-agent"; import { expandHomePath, resolveAllowedPath } from "./roots.js"; +import { + diagnoseRuntime, + openPathInFinder, + runCompatibilitySmoke, +} from "./runtime-operations.js"; import { commandWithAugmentedPath } from "./shell-environment.js"; +import { getExecutionCostSnapshot } from "./usage-meter.js"; type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; export type ToolResponse = { @@ -63,6 +69,100 @@ interface ApprovedShellCommand { } const approvedCommandPrefix = "devspace-approved "; +const runtimeCommandPrefix = "devspace-runtime"; + +function textResponse(text: string, isError = false): ToolResponse { + return { + content: [{ type: "text", text }], + ...(isError ? { isError: true } : {}), + }; +} + +function jsonResponse(value: unknown): ToolResponse { + return textResponse(JSON.stringify(value, null, 2)); +} + +async function runBuiltinRuntimeCommand( + input: BashToolInput, + context: ToolContext, +): Promise { + const rawCommand = String(input.command ?? "").trim(); + if ( + rawCommand !== runtimeCommandPrefix + && !rawCommand.startsWith(`${runtimeCommandPrefix} `) + ) return undefined; + + if ( + rawCommand === runtimeCommandPrefix + || rawCommand === `${runtimeCommandPrefix} help` + ) { + return textResponse([ + "Built-in DevSpace runtime commands:", + " devspace-runtime diagnose [--github] [command ...]", + " devspace-runtime smoke", + " devspace-runtime costs", + " devspace-runtime finder ", + ].join("\n")); + } + + if (rawCommand === `${runtimeCommandPrefix} costs`) { + return jsonResponse(getExecutionCostSnapshot()); + } + + if (rawCommand === `${runtimeCommandPrefix} smoke`) { + const result = await runCompatibilitySmoke(context.root); + return { + ...jsonResponse(result), + ...(result.status === "failed" ? { isError: true } : {}), + }; + } + + if ( + rawCommand === `${runtimeCommandPrefix} diagnose` + || rawCommand.startsWith(`${runtimeCommandPrefix} diagnose `) + ) { + const args = rawCommand + .slice(`${runtimeCommandPrefix} diagnose`.length) + .trim() + .split(/\s+/u) + .filter(Boolean); + const checkGitHubAuth = args.includes("--github"); + const commands = args + .filter((arg) => arg !== "--github") + .filter((arg) => /^[A-Za-z0-9._+-]+$/u.test(arg)) + .slice(0, 20); + const result = await diagnoseRuntime({ + workspaceRoot: context.root, + commands: commands.length > 0 ? commands : undefined, + checkGitHubAuth, + }); + return jsonResponse(result); + } + + if (rawCommand.startsWith(`${runtimeCommandPrefix} finder `)) { + const rawPath = rawCommand + .slice(`${runtimeCommandPrefix} finder `.length) + .trim(); + const requestedPath = ( + (rawPath.startsWith("\"") && rawPath.endsWith("\"")) + || (rawPath.startsWith("'") && rawPath.endsWith("'")) + ) ? rawPath.slice(1, -1) : rawPath; + if (!requestedPath) { + return textResponse("Finder path is required.", true); + } + try { + const absolutePath = resolveAllowedPath(requestedPath, context.cwd, [context.root]); + return jsonResponse(await openPathInFinder(absolutePath)); + } catch (error) { + return textResponse(error instanceof Error ? error.message : String(error), true); + } + } + + return textResponse( + "Unknown devspace-runtime command. Run `devspace-runtime help`.", + true, + ); +} function approvedCommandsPath(): string { return resolve(expandHomePath( process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE @@ -201,6 +301,9 @@ export async function listDirectoryTool(input: LsToolInput, context: ToolContext } export async function runShellTool(input: BashToolInput, context: ToolContext): Promise { + const builtin = await runBuiltinRuntimeCommand(input, context); + if (builtin) return builtin; + let approved: { input: BashToolInput; cwd: string }; try { approved = await resolveApprovedShellAlias(input, context); diff --git a/src/register-v11-tools.ts b/src/register-v11-tools.ts index a1116c35..62352611 100644 --- a/src/register-v11-tools.ts +++ b/src/register-v11-tools.ts @@ -11,12 +11,7 @@ import { import { runDesignAudit } from "./design-audit.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; import { matchWorkspaceSkills } from "./skill-matcher.js"; -import { - diagnoseRuntime, - openPathInFinder, - runCompatibilitySmoke, -} from "./runtime-operations.js"; -import { getExecutionCostSnapshot } from "./usage-meter.js"; +import { openPathInFinder } from "./runtime-operations.js"; import type { Workspace, WorkspaceRegistry } from "./workspaces.js"; const metricsSchema = z.object({ @@ -42,7 +37,7 @@ export function registerV11Tools( localAgentProviders: LocalAgentProviderAvailability[]; }, ): void { - registerRuntimeTools(server, input); + registerFinderAppTool(server, input); const enabledTools = new Set(enabledV11ToolNames(input.config)); if (enabledTools.has("match_skills")) { registerAppTool( @@ -145,7 +140,7 @@ export function registerV11Tools( } } -function registerRuntimeTools( +function registerFinderAppTool( server: McpServer, input: { config: ServerConfig; @@ -153,141 +148,12 @@ function registerRuntimeTools( localAgentProviders: LocalAgentProviderAvailability[]; }, ): void { - registerAppTool( - server, - "diagnose_runtime", - { - title: "Diagnose runtime", - description: "Diagnose workspace access, safe shell PATH fallbacks, executable discovery, Git state, and optional GitHub CLI authentication without returning credentials or token contents.", - inputSchema: { - workspaceId: z.string(), - commands: z.array(z.string().regex(/^[A-Za-z0-9._+-]+$/u)).max(20).optional(), - checkGitHubAuth: z.boolean().optional(), - }, - outputSchema: { - platform: z.string(), - nodeVersion: z.string(), - workspace: z.object({ - root: z.string(), - name: z.string(), - accessible: z.boolean(), - gitRepository: z.boolean(), - }), - shellPath: z.object({ - entryCount: z.number().int().nonnegative(), - addedEntries: z.array(z.string()), - }), - executables: z.array(z.object({ - command: z.string(), - found: z.boolean(), - path: z.string().optional(), - })), - githubAuthentication: z.enum(["not_checked", "authenticated", "unauthenticated", "gh_unavailable"]), - diagnostics: z.array(z.string()), - metrics: metricsSchema, - }, - _meta: {}, - annotations: readOnlyAnnotations, - }, - async ({ workspaceId, commands, checkGitHubAuth }) => { - const workspace = input.workspaces.getWorkspace(workspaceId); - const result = await diagnoseRuntime({ - workspaceRoot: workspace.root, - commands, - checkGitHubAuth, - }); - return toolResult( - result, - `Runtime diagnosis: ${result.executables.filter((entry) => entry.found).length}/${result.executables.length} executables available; GitHub auth ${result.githubAuthentication}.`, - ); - }, - ); - - registerAppTool( - server, - "compatibility_smoke_test", - { - title: "Compatibility smoke test", - description: "Run a bounded read-only compatibility smoke test for workspace access, listing, reading, text search, augmented shell PATH, Git, and MCP App resources.", - inputSchema: { - workspaceId: z.string(), - }, - outputSchema: { - status: z.enum(["passed", "failed"]), - steps: z.array(z.object({ - name: z.string(), - status: z.enum(["passed", "failed", "skipped"]), - detail: z.string(), - durationMs: z.number().int().nonnegative(), - })), - summary: z.object({ - passed: z.number().int().nonnegative(), - failed: z.number().int().nonnegative(), - skipped: z.number().int().nonnegative(), - }), - metrics: metricsSchema, - }, - _meta: {}, - annotations: readOnlyAnnotations, - }, - async ({ workspaceId }) => { - const workspace = input.workspaces.getWorkspace(workspaceId); - const result = await runCompatibilitySmoke(workspace.root); - return { - ...toolResult( - result, - `Compatibility smoke test ${result.status}: ${result.summary.passed} passed, ${result.summary.failed} failed, ${result.summary.skipped} skipped.`, - ), - isError: result.status === "failed", - }; - }, - ); - - registerAppTool( - server, - "execution_costs", - { - title: "Execution costs", - description: "Return observed DevSpace execution duration, tool calls, errors, retries, character volume, and estimated text tokens for the current server process.", - inputSchema: {}, - outputSchema: { - observedTokens: z.number().int().nonnegative(), - savedTokens: z.number().int().nonnegative(), - totalDurationMs: z.number().int().nonnegative(), - calls: z.number().int().nonnegative(), - errors: z.number().int().nonnegative(), - retries: z.number().int().nonnegative(), - byTool: z.record(z.string(), z.object({ - observedTokens: z.number().int().nonnegative(), - savedTokens: z.number().int().nonnegative(), - calls: z.number().int().nonnegative(), - inputChars: z.number().int().nonnegative(), - outputChars: z.number().int().nonnegative(), - payloadChars: z.number().int().nonnegative(), - totalDurationMs: z.number().int().nonnegative(), - errorCalls: z.number().int().nonnegative(), - retries: z.number().int().nonnegative(), - })), - note: z.string(), - }, - _meta: {}, - annotations: readOnlyAnnotations, - }, - async () => { - const result = getExecutionCostSnapshot(); - return toolResult( - result, - `Execution costs: ${result.calls} calls, ${result.totalDurationMs}ms, ${result.errors} errors, ~${result.observedTokens} observed tokens.`, - ); - }, - ); - registerAppTool( server, "open_in_finder", { title: "Open in Finder", - description: "Open a workspace-scoped local path in macOS Finder after validating that it remains inside the open workspace. Files are revealed; directories are opened. This is intended for an explicit user click or request.", + description: "Open a workspace-scoped local path in macOS Finder after validating that it remains inside the open workspace. Files are revealed; directories are opened. This Tool is callable only by the MCP App UI.", inputSchema: { workspaceId: z.string(), path: z.string().min(1).max(4_000), @@ -297,7 +163,11 @@ function registerRuntimeTools( path: z.string(), kind: z.enum(["file", "directory"]), }, - _meta: {}, + _meta: { + ui: { + visibility: ["app"], + }, + }, annotations: { readOnlyHint: true, destructiveHint: false, diff --git a/src/server.ts b/src/server.ts index 2100d937..f1153600 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1701,8 +1701,8 @@ function createMcpServer( { title: "Bash", description: config.toolMode !== "full" - ? `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.` - : `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.`, + ? `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Built-in commands are available without adding more MCP Tools: devspace-runtime diagnose [--github] [command ...], devspace-runtime smoke, devspace-runtime costs, and devspace-runtime finder for an explicit user request. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.` + : `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Built-in commands are available without adding more MCP Tools: devspace-runtime diagnose [--github] [command ...], devspace-runtime smoke, devspace-runtime costs, and devspace-runtime finder for an explicit user request. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.`, inputSchema: { workspaceId: z .string() @@ -1710,7 +1710,7 @@ function createMcpServer( command: z .string() .describe( - `Shell command to run. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes.`, + `Shell command to run. Use devspace-runtime diagnose, smoke, or costs for built-in diagnostics; use devspace-runtime finder only on explicit request. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes.`, ), workingDirectory: z .string() From ec43c3729270dd6a256c06fff804c9af4cc52bbe Mon Sep 17 00:00:00 2001 From: uniplanck <198168437+uniplanck@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:52:36 +0900 Subject: [PATCH 4/4] docs: extend compatibility kit with runtime reliability --- .../CHANGELOG.md | 11 + .../README.md | 10 +- .../docs/TEST_MATRIX.md | 15 + .../manifest.json | 17 +- .../0004-runtime-diagnostics-and-costs.patch | 809 ++++++++++++++++++ ...05-existing-tool-runtime-integration.patch | 453 ++++++++++ .../patches/0006-finder-app-action.patch | 141 +++ src/shell-environment.test.ts | 8 +- 8 files changed, 1457 insertions(+), 7 deletions(-) create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/patches/0004-runtime-diagnostics-and-costs.patch create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/patches/0005-existing-tool-runtime-integration.patch create mode 100644 compatibility-kit/openai-model-compatibility-2026-07/patches/0006-finder-app-action.patch diff --git a/compatibility-kit/openai-model-compatibility-2026-07/CHANGELOG.md b/compatibility-kit/openai-model-compatibility-2026-07/CHANGELOG.md index 1c706499..e6b1c175 100644 --- a/compatibility-kit/openai-model-compatibility-2026-07/CHANGELOG.md +++ b/compatibility-kit/openai-model-compatibility-2026-07/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026.07.2 + +- Added safe standard PATH augmentation without sourcing login-shell files. +- Added bounded runtime diagnosis and compatibility smoke checks through the + existing Bash Tool instead of expanding the model-facing Tool catalog. +- Expanded usage estimates into execution-cost summaries with observed duration, + calls, errors, retries, and character volume. +- Added workspace-scoped Finder integration as an app-only Tool and result-card + action, with root-guard validation. +- Added dedicated runtime tests and three ordered compatibility patches. + ## 2026.07.1 - Added compact `open_workspace` payload behavior with bounded instruction diff --git a/compatibility-kit/openai-model-compatibility-2026-07/README.md b/compatibility-kit/openai-model-compatibility-2026-07/README.md index 3a9cd85c..21bf4fb6 100644 --- a/compatibility-kit/openai-model-compatibility-2026-07/README.md +++ b/compatibility-kit/openai-model-compatibility-2026-07/README.md @@ -15,12 +15,15 @@ GPT-5.6-only patch. No assumption is made about undocumented OpenAI internals. excerpts and lazy full-file reads. - Keeps advertised instruction files readable without widening the workspace filesystem allowlist. -- Adds optional text-volume metrics so maintainers can measure response size and - identify avoidable context expansion. +- Adds execution-cost diagnostics for response volume, duration, Tool calls, + errors, and retries while keeping token values explicitly approximate. - Adds opt-in compound inspection tools for common, bounded read-only workflows. - Adds safer support for explicitly pre-approved shell command aliases. - Improves built-in agent profile, skill matching, design-audit, and MCP App integration paths while keeping the features opt-in. +- Adds safe standard PATH discovery without sourcing login-shell configuration. +- Integrates runtime diagnosis, compatibility smoke checks, cost snapshots, and + workspace-scoped Finder actions into the existing Tool surface. - Includes tests for the compatibility behavior and feature flags. ## Scope and privacy @@ -73,6 +76,9 @@ The patch set is split by responsibility: 1. `0001-compact-workspace-and-usage.patch` 2. `0002-safe-tools-and-compound-inspection.patch` 3. `0003-agents-skills-app-integration.patch` +4. `0004-runtime-diagnostics-and-costs.patch` +5. `0005-existing-tool-runtime-integration.patch` +6. `0006-finder-app-action.patch` The same changes are also present directly in the pull-request branch, so the maintainer can review normal source diffs without running the updater. diff --git a/compatibility-kit/openai-model-compatibility-2026-07/docs/TEST_MATRIX.md b/compatibility-kit/openai-model-compatibility-2026-07/docs/TEST_MATRIX.md index f4439f6d..b015a5d3 100644 --- a/compatibility-kit/openai-model-compatibility-2026-07/docs/TEST_MATRIX.md +++ b/compatibility-kit/openai-model-compatibility-2026-07/docs/TEST_MATRIX.md @@ -37,6 +37,21 @@ The public branch must pass: - Arbitrary normal shell commands continue through the existing shell path and are not rewritten as approved aliases. +### Runtime reliability + +- Standard executable locations are added only when present. +- Login-shell startup files are not sourced. +- `devspace-runtime diagnose` reports executable availability without returning + credentials or authentication values. +- `devspace-runtime smoke` completes bounded workspace, file, PATH, Git, and MCP + App checks. +- `devspace-runtime costs` reflects observed calls, duration, errors, retries, + character volume, and approximate text tokens for the active server process. +- Finder paths inside the workspace are accepted on macOS; paths outside the + workspace are rejected by the root guard. +- The Finder server action is app-only and does not increase the model-facing + Tool catalog. + ### Optional capabilities - Skill matcher disabled by default. diff --git a/compatibility-kit/openai-model-compatibility-2026-07/manifest.json b/compatibility-kit/openai-model-compatibility-2026-07/manifest.json index 731e87a5..5ae28e3e 100644 --- a/compatibility-kit/openai-model-compatibility-2026-07/manifest.json +++ b/compatibility-kit/openai-model-compatibility-2026-07/manifest.json @@ -1,6 +1,6 @@ { "name": "devspace-openai-model-compatibility", - "bundleVersion": "2026.07.1", + "bundleVersion": "2026.07.2", "status": "community-proposal", "target": { "package": "@waishnav/devspace", @@ -22,6 +22,21 @@ "order": 3, "path": "patches/0003-agents-skills-app-integration.patch", "scope": "Agent profiles, skills, design audit, configuration docs, and MCP App integration" + }, + { + "order": 4, + "path": "patches/0004-runtime-diagnostics-and-costs.patch", + "scope": "Safe PATH discovery, runtime diagnostics, compatibility smoke checks, and execution-cost aggregation" + }, + { + "order": 5, + "path": "patches/0005-existing-tool-runtime-integration.patch", + "scope": "Runtime commands integrated into the existing Bash Tool plus app-only Finder registration" + }, + { + "order": 6, + "path": "patches/0006-finder-app-action.patch", + "scope": "Workspace-scoped Open in Finder action for MCP App result cards" } ], "defaults": { diff --git a/compatibility-kit/openai-model-compatibility-2026-07/patches/0004-runtime-diagnostics-and-costs.patch b/compatibility-kit/openai-model-compatibility-2026-07/patches/0004-runtime-diagnostics-and-costs.patch new file mode 100644 index 00000000..acf7e428 --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/patches/0004-runtime-diagnostics-and-costs.patch @@ -0,0 +1,809 @@ +diff --git a/package.json b/package.json +index b6ad4b4fe3185b850a0dc94506293e09965be2c8..f5b002236bc2af529ec5294e193a8d8a7216f64e 100644 +--- a/package.json ++++ b/package.json +@@ -29,7 +29,8 @@ + "dev": "node scripts/dev-server.mjs", + "postinstall": "node scripts/fix-node-pty-permissions.mjs", + "start": "node dist/cli.js serve", +- "test": "tsx src/config.test.ts && tsx src/usage-meter.test.ts && tsx src/pi-tools.test.ts && tsx src/skill-matcher.test.ts && tsx src/compound-tools.test.ts && tsx src/design-audit.test.ts && tsx src/register-v11-tools.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", ++ "test": "tsx src/config.test.ts && tsx src/shell-environment.test.ts && tsx src/runtime-operations.test.ts && tsx src/usage-meter.test.ts && tsx src/pi-tools.test.ts && tsx src/skill-matcher.test.ts && tsx src/compound-tools.test.ts && tsx src/design-audit.test.ts && tsx src/register-v11-tools.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", ++ "test:runtime": "tsx src/shell-environment.test.ts && tsx src/runtime-operations.test.ts && tsx src/usage-meter.test.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "keywords": [], +diff --git a/src/runtime-operations.test.ts b/src/runtime-operations.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..ba2b2fda3cde935879be4bca09e3add16b3d10cc +--- /dev/null ++++ b/src/runtime-operations.test.ts +@@ -0,0 +1,45 @@ ++import assert from "node:assert/strict"; ++import { execFile } from "node:child_process"; ++import { mkdtemp, rm, writeFile } from "node:fs/promises"; ++import { tmpdir } from "node:os"; ++import { join } from "node:path"; ++import { promisify } from "node:util"; ++import { ++ diagnoseRuntime, ++ finderOpenArguments, ++ runCompatibilitySmoke, ++} from "./runtime-operations.js"; ++ ++const execFileAsync = promisify(execFile); ++const root = await mkdtemp(join(tmpdir(), "devspace-runtime-operations-")); ++try { ++ await writeFile(join(root, "AGENTS.md"), "Test instructions.\n", "utf8"); ++ await writeFile(join(root, "package.json"), '{"name":"runtime-test"}\n', "utf8"); ++ await execFileAsync("git", ["init", "-q"], { cwd: root }); ++ ++ const diagnostics = await diagnoseRuntime({ ++ workspaceRoot: root, ++ commands: ["git", "node", "definitely-missing-command"], ++ checkGitHubAuth: false, ++ }); ++ assert.equal(diagnostics.workspace.accessible, true); ++ assert.equal(diagnostics.workspace.gitRepository, true); ++ assert.equal(diagnostics.executables.find((entry) => entry.command === "git")?.found, true); ++ assert.equal( ++ diagnostics.executables.find((entry) => entry.command === "definitely-missing-command")?.found, ++ false, ++ ); ++ assert.equal(diagnostics.githubAuthentication, "not_checked"); ++ ++ const smoke = await runCompatibilitySmoke(root); ++ assert.equal(smoke.status, "passed"); ++ assert.equal(smoke.summary.failed, 0); ++ assert.equal(smoke.steps.find((step) => step.name === "workspace")?.status, "passed"); ++ assert.equal(smoke.steps.find((step) => step.name === "git")?.status, "passed"); ++ ++ assert.deepEqual(finderOpenArguments("/tmp/file.txt", "file", "darwin"), ["-R", "/tmp/file.txt"]); ++ assert.deepEqual(finderOpenArguments("/tmp/folder", "directory", "darwin"), ["/tmp/folder"]); ++ assert.equal(finderOpenArguments("C:\\temp", "directory", "win32"), undefined); ++} finally { ++ await rm(root, { recursive: true, force: true }); ++} +diff --git a/src/runtime-operations.ts b/src/runtime-operations.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..d1d7a59e693d9cdd384c057fe5ab325533abbc41 +--- /dev/null ++++ b/src/runtime-operations.ts +@@ -0,0 +1,318 @@ ++import { execFile } from "node:child_process"; ++import { access, readdir, readFile, stat } from "node:fs/promises"; ++import { basename, join } from "node:path"; ++import { promisify } from "node:util"; ++import { measuredPayload } from "./tool-metrics.js"; ++import { resolveExecutable, shellPathInfo } from "./shell-environment.js"; ++ ++const execFileAsync = promisify(execFile); ++ ++export interface RuntimeDiagnosticResult { ++ platform: NodeJS.Platform; ++ nodeVersion: string; ++ workspace: { ++ root: string; ++ name: string; ++ accessible: boolean; ++ gitRepository: boolean; ++ }; ++ shellPath: { ++ entryCount: number; ++ addedEntries: string[]; ++ }; ++ executables: Array<{ ++ command: string; ++ found: boolean; ++ path?: string; ++ }>; ++ githubAuthentication: "not_checked" | "authenticated" | "unauthenticated" | "gh_unavailable"; ++ diagnostics: string[]; ++ metrics: { ++ serverDurationMs: number; ++ payloadCharacters: number; ++ returnedItems: number; ++ truncated: boolean; ++ }; ++} ++ ++export async function diagnoseRuntime(input: { ++ workspaceRoot: string; ++ commands?: string[]; ++ checkGitHubAuth?: boolean; ++}): Promise { ++ const startedAt = performance.now(); ++ const commands = Array.from(new Set(input.commands ?? ["git", "node", "npm", "gh"])) ++ .filter((command) => /^[A-Za-z0-9._+-]+$/.test(command)) ++ .slice(0, 20); ++ const shell = shellPathInfo(); ++ const executables = commands.map((command) => { ++ const path = resolveExecutable(command); ++ return { command, found: Boolean(path), ...(path ? { path } : {}) }; ++ }); ++ const accessible = await pathAccessible(input.workspaceRoot); ++ const gitRepository = accessible && await isGitRepository(input.workspaceRoot); ++ const ghPath = executables.find((entry) => entry.command === "gh")?.path; ++ let githubAuthentication: RuntimeDiagnosticResult["githubAuthentication"] = "not_checked"; ++ if (input.checkGitHubAuth) { ++ if (!ghPath) { ++ githubAuthentication = "gh_unavailable"; ++ } else { ++ githubAuthentication = await commandSucceeds( ++ ghPath, ++ ["auth", "status", "--hostname", "github.com"], ++ input.workspaceRoot, ++ ) ? "authenticated" : "unauthenticated"; ++ } ++ } ++ ++ const diagnostics = [ ++ accessible ? "Workspace is accessible." : "Workspace is not accessible.", ++ gitRepository ? "Git repository detected." : "Git repository not detected.", ++ shell.addedEntries.length > 0 ++ ? `Added ${shell.addedEntries.length} safe PATH fallback(s).` ++ : "No PATH fallbacks were needed.", ++ ...executables ++ .filter((entry) => !entry.found) ++ .map((entry) => `Executable not found: ${entry.command}`), ++ input.checkGitHubAuth ++ ? `GitHub authentication: ${githubAuthentication}.` ++ : "GitHub authentication was not checked.", ++ ]; ++ ++ return measuredPayload({ ++ platform: process.platform, ++ nodeVersion: process.version, ++ workspace: { ++ root: input.workspaceRoot, ++ name: basename(input.workspaceRoot), ++ accessible, ++ gitRepository, ++ }, ++ shellPath: { ++ entryCount: shell.entries.length, ++ addedEntries: shell.addedEntries, ++ }, ++ executables, ++ githubAuthentication, ++ diagnostics, ++ }, { ++ startedAt, ++ returnedItems: executables.length + diagnostics.length, ++ truncated: false, ++ }); ++} ++ ++export interface CompatibilitySmokeResult { ++ status: "passed" | "failed"; ++ steps: Array<{ ++ name: string; ++ status: "passed" | "failed" | "skipped"; ++ detail: string; ++ durationMs: number; ++ }>; ++ summary: { ++ passed: number; ++ failed: number; ++ skipped: number; ++ }; ++ metrics: { ++ serverDurationMs: number; ++ payloadCharacters: number; ++ returnedItems: number; ++ truncated: boolean; ++ }; ++} ++ ++export async function runCompatibilitySmoke( ++ workspaceRoot: string, ++): Promise { ++ const startedAt = performance.now(); ++ const steps: CompatibilitySmokeResult["steps"] = []; ++ ++ await smokeStep(steps, "workspace", async () => { ++ const info = await stat(workspaceRoot); ++ if (!info.isDirectory()) throw new Error("Workspace root is not a directory."); ++ return "Workspace directory is accessible."; ++ }); ++ ++ await smokeStep(steps, "list", async () => { ++ const entries = await readdir(workspaceRoot); ++ return `Listed ${entries.length} root item(s).`; ++ }); ++ ++ const readableCandidate = await firstAccessible([ ++ join(workspaceRoot, "AGENTS.md"), ++ join(workspaceRoot, "package.json"), ++ join(workspaceRoot, "README.md"), ++ ]); ++ if (readableCandidate) { ++ await smokeStep(steps, "read", async () => { ++ const content = await readFile(readableCandidate, "utf8"); ++ if (content.length === 0) throw new Error("Selected text file is empty."); ++ return `Read ${basename(readableCandidate)} (${content.length} characters).`; ++ }); ++ await smokeStep(steps, "search", async () => { ++ const content = await readFile(readableCandidate, "utf8"); ++ const lines = content.split(/\r?\n/u).filter((line) => /\S/u.test(line)); ++ if (lines.length === 0) throw new Error("No searchable text was found."); ++ return `Found ${lines.length} non-empty line(s).`; ++ }); ++ } else { ++ steps.push({ ++ name: "read", ++ status: "skipped", ++ detail: "No standard text file was available.", ++ durationMs: 0, ++ }); ++ steps.push({ ++ name: "search", ++ status: "skipped", ++ detail: "No standard text file was available.", ++ durationMs: 0, ++ }); ++ } ++ ++ await smokeStep(steps, "shell-path", async () => { ++ const executable = resolveExecutable("node"); ++ if (!executable) throw new Error("Node executable was not resolved from the augmented PATH."); ++ const { stdout } = await execFileAsync(executable, ["-e", "process.stdout.write(process.cwd())"], { ++ cwd: workspaceRoot, ++ timeout: 5_000, ++ maxBuffer: 8_192, ++ }); ++ if (!stdout.trim()) throw new Error("Shell probe returned no working directory."); ++ return "Resolved and executed Node through the augmented PATH."; ++ }); ++ ++ const gitPath = resolveExecutable("git"); ++ if (gitPath && await isGitRepository(workspaceRoot)) { ++ await smokeStep(steps, "git", async () => { ++ await execFileAsync(gitPath, ["status", "--short", "--branch"], { ++ cwd: workspaceRoot, ++ timeout: 5_000, ++ maxBuffer: 64_000, ++ }); ++ return "Git status completed."; ++ }); ++ } else { ++ steps.push({ ++ name: "git", ++ status: "skipped", ++ detail: gitPath ? "Workspace is not a Git repository." : "Git executable was not found.", ++ durationMs: 0, ++ }); ++ } ++ ++ const appResource = join(workspaceRoot, "src", "ui", "workspace-app.html"); ++ steps.push({ ++ name: "app-resource", ++ status: await pathAccessible(appResource) ? "passed" : "skipped", ++ detail: await pathAccessible(appResource) ++ ? "MCP App resource entry point is present." ++ : "No MCP App resource entry point was detected in this workspace.", ++ durationMs: 0, ++ }); ++ ++ const summary = { ++ passed: steps.filter((step) => step.status === "passed").length, ++ failed: steps.filter((step) => step.status === "failed").length, ++ skipped: steps.filter((step) => step.status === "skipped").length, ++ }; ++ ++ return measuredPayload({ ++ status: summary.failed === 0 ? "passed" : "failed", ++ steps, ++ summary, ++ }, { ++ startedAt, ++ returnedItems: steps.length, ++ truncated: false, ++ }); ++} ++ ++export function finderOpenArguments( ++ path: string, ++ kind: "file" | "directory", ++ platform: NodeJS.Platform = process.platform, ++): string[] | undefined { ++ if (platform !== "darwin") return undefined; ++ return kind === "directory" ? [path] : ["-R", path]; ++} ++ ++export async function openPathInFinder(path: string): Promise<{ ++ status: "opened" | "unsupported"; ++ path: string; ++ kind: "file" | "directory"; ++}> { ++ const info = await stat(path); ++ const kind = info.isDirectory() ? "directory" : "file"; ++ const args = finderOpenArguments(path, kind); ++ if (!args) { ++ return { status: "unsupported", path, kind }; ++ } ++ const openExecutable = resolveExecutable("open") ?? "/usr/bin/open"; ++ await execFileAsync(openExecutable, args, { ++ timeout: 10_000, ++ maxBuffer: 8_192, ++ }); ++ return { status: "opened", path, kind }; ++} ++ ++async function smokeStep( ++ steps: CompatibilitySmokeResult["steps"], ++ name: string, ++ operation: () => Promise, ++): Promise { ++ const startedAt = performance.now(); ++ try { ++ const detail = await operation(); ++ steps.push({ ++ name, ++ status: "passed", ++ detail, ++ durationMs: Math.max(0, Math.round(performance.now() - startedAt)), ++ }); ++ } catch (error) { ++ steps.push({ ++ name, ++ status: "failed", ++ detail: error instanceof Error ? error.message : String(error), ++ durationMs: Math.max(0, Math.round(performance.now() - startedAt)), ++ }); ++ } ++} ++ ++async function pathAccessible(path: string): Promise { ++ try { ++ await access(path); ++ return true; ++ } catch { ++ return false; ++ } ++} ++ ++async function firstAccessible(paths: string[]): Promise { ++ for (const path of paths) { ++ if (await pathAccessible(path)) return path; ++ } ++ return undefined; ++} ++ ++async function isGitRepository(cwd: string): Promise { ++ const gitPath = resolveExecutable("git"); ++ if (!gitPath) return false; ++ return commandSucceeds(gitPath, ["rev-parse", "--is-inside-work-tree"], cwd); ++} ++ ++async function commandSucceeds(command: string, args: string[], cwd: string): Promise { ++ try { ++ await execFileAsync(command, args, { ++ cwd, ++ timeout: 5_000, ++ maxBuffer: 8_192, ++ }); ++ return true; ++ } catch { ++ return false; ++ } ++} +diff --git a/src/shell-environment.test.ts b/src/shell-environment.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..0ddadb2b410699004ddb42455499bba8aa0b0993 +--- /dev/null ++++ b/src/shell-environment.test.ts +@@ -0,0 +1,46 @@ ++import assert from "node:assert/strict"; ++import { delimiter } from "node:path"; ++import { ++ commandWithAugmentedPath, ++ resolveExecutable, ++ shellPathInfo, ++} from "./shell-environment.js"; ++ ++const existing = ["/usr/bin", "/bin"].join(delimiter); ++const existingDirs = new Set([ ++ "/opt/homebrew/bin", ++ "/usr/local/bin", ++ "/test-home/.local/bin", ++ "/usr/bin", ++ "/bin", ++]); ++const info = shellPathInfo( ++ { PATH: existing }, ++ "darwin", ++ "/test-home", ++ (path) => existingDirs.has(path), ++); ++assert.deepEqual(info.entries, [ ++ "/usr/bin", ++ "/bin", ++ "/opt/homebrew/bin", ++ "/usr/local/bin", ++ "/test-home/.local/bin", ++]); ++assert.deepEqual(info.addedEntries, [ ++ "/opt/homebrew/bin", ++ "/usr/local/bin", ++ "/test-home/.local/bin", ++]); ++assert.match(commandWithAugmentedPath("gh auth status", { PATH: existing }, "darwin"), /^export PATH='/); ++assert.equal(commandWithAugmentedPath("dir", { PATH: existing }, "win32"), "dir"); ++assert.equal( ++ resolveExecutable( ++ "gh", ++ { PATH: existing }, ++ "darwin", ++ (path) => path === "/opt/homebrew/bin/gh" || existingDirs.has(path), ++ ), ++ "/opt/homebrew/bin/gh", ++); ++assert.equal(resolveExecutable("bad command", { PATH: existing }, "darwin"), undefined); +diff --git a/src/shell-environment.ts b/src/shell-environment.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..2983d421044fba5d01031e9f27fca367a3b54230 +--- /dev/null ++++ b/src/shell-environment.ts +@@ -0,0 +1,89 @@ ++import { existsSync } from "node:fs"; ++import { homedir } from "node:os"; ++import { delimiter, join } from "node:path"; ++ ++export interface ShellPathInfo { ++ path: string; ++ entries: string[]; ++ addedEntries: string[]; ++} ++ ++function uniqueEntries(entries: string[]): string[] { ++ const seen = new Set(); ++ return entries.filter((entry) => { ++ const normalized = entry.trim(); ++ if (!normalized || seen.has(normalized)) return false; ++ seen.add(normalized); ++ return true; ++ }); ++} ++ ++export function shellPathInfo( ++ env: NodeJS.ProcessEnv = process.env, ++ platform: NodeJS.Platform = process.platform, ++ home: string = homedir(), ++ directoryExists: (path: string) => boolean = existsSync, ++): ShellPathInfo { ++ const existingEntries = String(env.PATH ?? "") ++ .split(delimiter) ++ .filter(Boolean); ++ const candidates = platform === "win32" ++ ? [] ++ : [ ++ "/opt/homebrew/bin", ++ "/opt/homebrew/sbin", ++ "/usr/local/bin", ++ "/usr/local/sbin", ++ join(home, ".local", "bin"), ++ join(home, "bin"), ++ "/usr/bin", ++ "/bin", ++ "/usr/sbin", ++ "/sbin", ++ ]; ++ const existingSet = new Set(existingEntries); ++ const addedEntries = uniqueEntries(candidates) ++ .filter((entry) => !existingSet.has(entry) && directoryExists(entry)); ++ const entries = uniqueEntries([...existingEntries, ...addedEntries]); ++ ++ return { ++ path: entries.join(delimiter), ++ entries, ++ addedEntries, ++ }; ++} ++ ++function quotePosix(value: string): string { ++ return `'${value.replaceAll("'", `'"'"'`)}'`; ++} ++ ++export function commandWithAugmentedPath( ++ command: string, ++ env: NodeJS.ProcessEnv = process.env, ++ platform: NodeJS.Platform = process.platform, ++): string { ++ if (platform === "win32") return command; ++ const info = shellPathInfo(env, platform); ++ return `export PATH=${quotePosix(info.path)}; ${command}`; ++} ++ ++export function resolveExecutable( ++ command: string, ++ env: NodeJS.ProcessEnv = process.env, ++ platform: NodeJS.Platform = process.platform, ++ fileExists: (path: string) => boolean = existsSync, ++): string | undefined { ++ if (!/^[A-Za-z0-9._+-]+$/.test(command)) return undefined; ++ const info = shellPathInfo(env, platform); ++ const extensions = platform === "win32" ++ ? String(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") ++ : [""]; ++ ++ for (const entry of info.entries) { ++ for (const extension of extensions) { ++ const candidate = join(entry, `${command}${extension}`); ++ if (fileExists(candidate)) return candidate; ++ } ++ } ++ return undefined; ++} +diff --git a/src/usage-meter.test.ts b/src/usage-meter.test.ts +index 448dc8e1a721c850a910a71787737586997cc565..f18fec430d09b160c58ef3c9323b88de30f06997 100644 +--- a/src/usage-meter.test.ts ++++ b/src/usage-meter.test.ts +@@ -2,9 +2,11 @@ import assert from "node:assert/strict"; + import { platform } from "node:os"; + import { + appendUsageToContent, ++ compactDuration, + compactTokenCount, + editInputChars, + estimateTokensFromChars, ++ getExecutionCostSnapshot, + recordObservedToolUsage, + textContentChars, + } from "./usage-meter.js"; +@@ -13,6 +15,8 @@ assert.equal(estimateTokensFromChars(0), 0); + assert.equal(estimateTokensFromChars(5), 2); + assert.equal(compactTokenCount(999), "999"); + assert.equal(compactTokenCount(1_500), "1.5k"); ++assert.equal(compactDuration(950), "950ms"); ++assert.equal(compactDuration(1_500), "1.5s"); + assert.equal(editInputChars([{ oldText: "old", newText: "new" }]), 6); + assert.equal(textContentChars([{ type: "text", text: "hello" }]), 5); + +@@ -22,22 +26,35 @@ const usage = recordObservedToolUsage({ + tool: "read", + observedChars: 40, + savedChars: 80, ++ inputChars: 10, ++ outputChars: 30, ++ payloadChars: 40, ++ durationMs: 125, ++ error: false, ++ retries: 1, + }); + const content = [{ type: "text" as const, text: "result" }]; + ++assert.equal(usage.durationMs, 125); ++assert.equal(usage.sessionCalls >= 1, true); + assert.deepEqual(appendUsageToContent(content, usage, "off"), content); + const compactContent = appendUsageToContent(content, usage, "compact"); + const compactText = compactContent.at(-1); + assert.match( + compactText?.type === "text" ? compactText.text : "", +- /DevSpace token estimate/, ++ /DevSpace cost:/, + ); + const fullContent = appendUsageToContent(content, usage, "full"); + const fullText = fullContent.at(-1); + assert.match( + fullText?.type === "text" ? fullText.text : "", +- /not actual model usage or billing data/, ++ /Execution cost estimate/, + ); ++const snapshot = getExecutionCostSnapshot(); ++assert.equal(snapshot.calls >= 1, true); ++assert.equal(snapshot.byTool.read.calls >= 1, true); ++assert.equal(snapshot.byTool.read.totalDurationMs >= 125, true); ++assert.equal(snapshot.retries >= 1, true); + + if (previousHistory === undefined) { + delete process.env.DEVSPACE_USAGE_HISTORY; +diff --git a/src/usage-meter.ts b/src/usage-meter.ts +index 83f6b84dc7e225a67edb27d1b679f6046978713e..ec6401c6ddda14877f2ac2b5b3dc0c0a3ac64313 100644 +--- a/src/usage-meter.ts ++++ b/src/usage-meter.ts +@@ -7,10 +7,16 @@ import type { UsageContentMode } from "./config.js"; + type TextContent = { type: "text"; text: string }; + type ToolContent = TextContent | { type: "image"; data: string; mimeType: string }; + +-interface UsageBucket { ++export interface UsageBucket { + observedTokens: number; + savedTokens: number; + calls: number; ++ inputChars: number; ++ outputChars: number; ++ payloadChars: number; ++ totalDurationMs: number; ++ errorCalls: number; ++ retries: number; + } + + export interface UsageEntry { +@@ -25,8 +31,29 @@ export interface UsageEntry { + observedTokens: number; + savedChars: number; + savedTokens: number; ++ inputChars: number; ++ outputChars: number; ++ payloadChars: number; ++ durationMs: number; ++ error: boolean; ++ retries: number; + sessionObservedTokens: number; + sessionSavedTokens: number; ++ sessionDurationMs: number; ++ sessionCalls: number; ++ sessionErrors: number; ++ sessionRetries: number; ++ byTool: Record; ++ note: string; ++} ++ ++export interface ExecutionCostSnapshot { ++ observedTokens: number; ++ savedTokens: number; ++ totalDurationMs: number; ++ calls: number; ++ errors: number; ++ retries: number; + byTool: Record; + note: string; + } +@@ -34,6 +61,10 @@ export interface UsageEntry { + const session = { + observedTokens: 0, + savedTokens: 0, ++ totalDurationMs: 0, ++ calls: 0, ++ errors: 0, ++ retries: 0, + byTool: new Map(), + }; + let historyWrite = Promise.resolve(); +@@ -43,8 +74,8 @@ function historyPath(): string { + ?? join(homedir(), ".local", "share", "devspace", "usage-history.jsonl"); + } + +-function clampNumber(value: number): number { +- return Number.isFinite(value) && value > 0 ? Math.ceil(value) : 0; ++function clampNumber(value: number | undefined): number { ++ return Number.isFinite(value) && Number(value) > 0 ? Math.ceil(Number(value)) : 0; + } + + export function estimateTokensFromChars(chars: number): number { +@@ -80,9 +111,17 @@ export function compactTokenCount(tokens: number): string { + return `${tokens}`; + } + ++export function compactDuration(durationMs: number): string { ++ if (durationMs >= 60_000) return `${(durationMs / 60_000).toFixed(1)}m`; ++ if (durationMs >= 1_000) return `${(durationMs / 1_000).toFixed(1)}s`; ++ return `${durationMs}ms`; ++} ++ + function byToolSnapshot(): Record { + return Object.fromEntries( +- Array.from(session.byTool.entries()).sort(([a], [b]) => a.localeCompare(b)), ++ Array.from(session.byTool.entries()) ++ .sort(([a], [b]) => a.localeCompare(b)) ++ .map(([tool, bucket]) => [tool, { ...bucket }]), + ); + } + +@@ -103,24 +142,52 @@ export function recordObservedToolUsage(input: { + path?: string; + observedChars: number; + savedChars: number; ++ inputChars?: number; ++ outputChars?: number; ++ payloadChars?: number; ++ durationMs?: number; ++ error?: boolean; ++ retries?: number; + }): UsageEntry { + const tool = input.tool ?? "unknown"; + const observedChars = clampNumber(input.observedChars); + const savedChars = clampNumber(input.savedChars); + const observedTokens = estimateTokensFromChars(observedChars); + const savedTokens = estimateTokensFromChars(savedChars); ++ const inputChars = clampNumber(input.inputChars); ++ const outputChars = clampNumber(input.outputChars); ++ const payloadChars = clampNumber(input.payloadChars ?? observedChars); ++ const durationMs = clampNumber(input.durationMs); ++ const retries = clampNumber(input.retries); ++ const error = input.error === true; + const current = session.byTool.get(tool) ?? { + observedTokens: 0, + savedTokens: 0, + calls: 0, ++ inputChars: 0, ++ outputChars: 0, ++ payloadChars: 0, ++ totalDurationMs: 0, ++ errorCalls: 0, ++ retries: 0, + }; + + current.observedTokens += observedTokens; + current.savedTokens += savedTokens; + current.calls += 1; ++ current.inputChars += inputChars; ++ current.outputChars += outputChars; ++ current.payloadChars += payloadChars; ++ current.totalDurationMs += durationMs; ++ current.errorCalls += error ? 1 : 0; ++ current.retries += retries; + session.byTool.set(tool, current); + session.observedTokens += observedTokens; + session.savedTokens += savedTokens; ++ session.totalDurationMs += durationMs; ++ session.calls += 1; ++ session.errors += error ? 1 : 0; ++ session.retries += retries; + + const entry: UsageEntry = { + ts: new Date().toISOString(), +@@ -136,31 +203,55 @@ export function recordObservedToolUsage(input: { + observedTokens, + savedChars, + savedTokens, ++ inputChars, ++ outputChars, ++ payloadChars, ++ durationMs, ++ error, ++ retries, + sessionObservedTokens: session.observedTokens, + sessionSavedTokens: session.savedTokens, ++ sessionDurationMs: session.totalDurationMs, ++ sessionCalls: session.calls, ++ sessionErrors: session.errors, ++ sessionRetries: session.retries, + byTool: byToolSnapshot(), +- note: "DevSpace observed text estimate only. Not ChatGPT actual model usage.", ++ note: "DevSpace observed execution estimate only. Not host-model billing or actual model usage.", + }; + appendHistory(entry); + return entry; + } + ++export function getExecutionCostSnapshot(): ExecutionCostSnapshot { ++ return { ++ observedTokens: session.observedTokens, ++ savedTokens: session.savedTokens, ++ totalDurationMs: session.totalDurationMs, ++ calls: session.calls, ++ errors: session.errors, ++ retries: session.retries, ++ byTool: byToolSnapshot(), ++ note: "Text-token values are estimates; duration and call/error counts are observed by DevSpace.", ++ }; ++} ++ + function fullUsageSummaryText(entry: UsageEntry): string { + const byTool = Object.entries(entry.byTool) +- .map(([tool, value]) => `${tool} ${compactTokenCount(value.observedTokens)}`) ++ .map(([tool, value]) => `${tool} ${value.calls} calls/${compactDuration(value.totalDurationMs)}`) + .join(" / "); + + return [ +- "DevSpace token estimate:", +- `Observed this call: ~${compactTokenCount(entry.observedTokens)} / session: ~${compactTokenCount(entry.sessionObservedTokens)}`, +- `Session by tool: ${byTool || "none"}`, +- `Estimated text avoided: ~${compactTokenCount(entry.sessionSavedTokens)} tokens`, +- "Calculated only from text handled by DevSpace. This is not actual model usage or billing data.", ++ "Execution cost estimate:", ++ `Current: ~${compactTokenCount(entry.observedTokens)} tokens / ${compactDuration(entry.durationMs)} / ${entry.error ? "error" : "ok"}`, ++ `Session: ~${compactTokenCount(entry.sessionObservedTokens)} tokens / ${compactDuration(entry.sessionDurationMs)} / ${entry.sessionCalls} calls / ${entry.sessionErrors} errors / ${entry.sessionRetries} retries`, ++ `By tool: ${byTool || "none"}`, ++ `Estimated saved tokens: ~${compactTokenCount(entry.sessionSavedTokens)}`, ++ "Token values are estimated from text handled by DevSpace, not actual host-model usage.", + ].join("\n"); + } + + function compactUsageSummaryText(entry: UsageEntry): string { +- return `DevSpace token estimate: call ~${compactTokenCount(entry.observedTokens)} / session ~${compactTokenCount(entry.sessionObservedTokens)}`; ++ return `DevSpace cost: ~${compactTokenCount(entry.observedTokens)} tok · ${compactDuration(entry.durationMs)} | session ~${compactTokenCount(entry.sessionObservedTokens)} tok · ${entry.sessionCalls} calls · ${entry.sessionErrors} errors`; + } + + export function appendUsageToContent( diff --git a/compatibility-kit/openai-model-compatibility-2026-07/patches/0005-existing-tool-runtime-integration.patch b/compatibility-kit/openai-model-compatibility-2026-07/patches/0005-existing-tool-runtime-integration.patch new file mode 100644 index 00000000..abfe39b0 --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/patches/0005-existing-tool-runtime-integration.patch @@ -0,0 +1,453 @@ +diff --git a/docs/configuration.md b/docs/configuration.md +index cd4bfa6c8f5bb3ba1f7620278e14a9e69fbc2f22..113593e7d07eea45fbe2950601ab9e3b0f53f4a5 100644 +--- a/docs/configuration.md ++++ b/docs/configuration.md +@@ -87,16 +87,19 @@ sessions. + | `changes` | Enables the aggregate `show_changes` tool and attaches widget UI to `open_workspace` and `show_changes`. | + | `off` | Disables widget UI. This is the default in compact mode. | + +-## Compact payloads and usage estimates ++## Compact payloads and execution-cost estimates + + | Variable | Default | Purpose | + | --- | --- | --- | + | `DEVSPACE_OPEN_WORKSPACE_PAYLOAD` | `compact` | Use `compact` for bounded instruction excerpts and smaller skill metadata, or `full` for the v1.0 payload. | + | `DEVSPACE_OPEN_WORKSPACE_INSTRUCTION_CHARS` | `6000` | Maximum characters returned per instruction excerpt; minimum `256`. | +-| `DEVSPACE_USAGE_CONTENT` | `compact` | Append `compact` or `full` observed-token estimates to tool output, or use `off` to hide them. | +-| `DEVSPACE_USAGE_HISTORY` | `~/.local/share/devspace/usage-history.jsonl` | Local JSONL destination for non-blocking usage diagnostics. | ++| `DEVSPACE_USAGE_CONTENT` | `compact` | Append `compact` or `full` execution-cost summaries to tool output, or use `off` to hide them. | ++| `DEVSPACE_USAGE_HISTORY` | `~/.local/share/devspace/usage-history.jsonl` | Local JSONL destination for non-blocking execution diagnostics. | + +-Token counts are estimates from text handled by DevSpace, not ChatGPT model billing or actual model usage. ++Execution diagnostics record observed server duration, Tool call count, error count, retry count, ++input/output character volume, and text-token estimates. Token counts are estimates from text handled ++by DevSpace, not host-model billing or actual model usage. Run `devspace-runtime costs` through ++the existing Bash Tool for the current server-process aggregate. + + ## DevSpace v1.1 feature flags + +@@ -113,6 +116,33 @@ All v1.1 feature flags default to `0`, so the existing compact Tool catalog and + Feature flag values are strict: `1/0`, `true/false`, `yes/no`, and `on/off` are accepted. + New Tool results include `serverDurationMs`, `payloadCharacters`, `returnedItems`, and `truncated`. + ++## Runtime reliability commands ++ ++Runtime diagnostics are integrated into the existing Bash Tool instead of increasing the model-facing ++MCP Tool catalog. These exact commands are intercepted by DevSpace and do not start a login shell: ++ ++| Bash command | Purpose | ++| --- | --- | ++| `devspace-runtime diagnose [--github] [command ...]` | Classifies workspace access, Git detection, executable discovery, safe PATH fallbacks, and optional GitHub CLI authentication without returning credentials. | ++| `devspace-runtime smoke` | Runs bounded read-only checks for list/read/search, shell PATH resolution, Git, and MCP App resources. | ++| `devspace-runtime costs` | Returns observed duration, calls, errors, retries, character volume, and estimated text tokens from the current server process. | ++| `devspace-runtime finder ` | On macOS, opens a validated workspace directory or reveals a validated file in Finder after an explicit request. | ++ ++Shell execution augments the inherited PATH with existing standard locations such as ++`/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, and system binary directories. It does not ++source `.zshrc`, `.zprofile`, or another login-shell configuration, avoiding startup side effects. ++ ++When Widgets are enabled, Tool cards with a workspace path display a link-style **Open in Finder** ++action. The App calls the app-only `open_in_finder` Tool; it is hidden from the model-facing catalog. ++The server validates the path against the workspace before invoking macOS Finder. Paths outside the ++approved workspace are rejected by the normal root guard. ++ ++For a quick regression check after a host-model rollout, run `devspace-runtime smoke` through Bash or: ++ ++```bash ++npm run test:runtime ++``` ++ + The v1.1 package intentionally ships Design Audit as an adapter only: no Playwright/CDP/axe + runtime is currently bundled, no browser binary is downloaded, and an enabled Tool returns an + unavailable error until a real adapter is connected. A future adapter must use an ephemeral +diff --git a/src/pi-tools.test.ts b/src/pi-tools.test.ts +index 55f1a857b6762ef85aa879abe24c1e868926bd86..b606a669b4853359f4ca7f49bc452df03f128328 100644 +--- a/src/pi-tools.test.ts ++++ b/src/pi-tools.test.ts +@@ -27,6 +27,39 @@ try { + assert.equal(allowed.isError, undefined); + assert.match(allowed.content[0]?.type === "text" ? allowed.content[0].text : "", /nested/); + ++ const diagnosis = await runShellTool( ++ { command: "devspace-runtime diagnose node git" }, ++ { cwd: root, root }, ++ ); ++ assert.equal(diagnosis.isError, undefined); ++ const diagnosisText = diagnosis.content[0]?.type === "text" ? diagnosis.content[0].text : ""; ++ assert.match(diagnosisText, /"accessible": true/); ++ assert.match(diagnosisText, /"command": "node"/); ++ ++ const smoke = await runShellTool( ++ { command: "devspace-runtime smoke" }, ++ { cwd: root, root }, ++ ); ++ assert.equal(smoke.isError, undefined); ++ assert.match(smoke.content[0]?.type === "text" ? smoke.content[0].text : "", /"status": "passed"/); ++ ++ const costs = await runShellTool( ++ { command: "devspace-runtime costs" }, ++ { cwd: root, root }, ++ ); ++ assert.equal(costs.isError, undefined); ++ assert.match(costs.content[0]?.type === "text" ? costs.content[0].text : "", /"calls":/); ++ ++ const finderEscape = await runShellTool( ++ { command: "devspace-runtime finder ../outside" }, ++ { cwd: root, root }, ++ ); ++ assert.equal(finderEscape.isError, true); ++ assert.match( ++ finderEscape.content[0]?.type === "text" ? finderEscape.content[0].text : "", ++ /outside allowed roots/, ++ ); ++ + await writeFile(commandsFile, JSON.stringify({ + commands: [{ + alias: "escape", +diff --git a/src/pi-tools.ts b/src/pi-tools.ts +index dd72dd7227113d088ea5c6a3141e36bcc7c30e09..f01be3655625170492bb098aadd42ac8f0fe3e9c 100644 +--- a/src/pi-tools.ts ++++ b/src/pi-tools.ts +@@ -20,6 +20,13 @@ import { + type AgentToolResult, + } from "@earendil-works/pi-coding-agent"; + import { expandHomePath, resolveAllowedPath } from "./roots.js"; ++import { ++ diagnoseRuntime, ++ openPathInFinder, ++ runCompatibilitySmoke, ++} from "./runtime-operations.js"; ++import { commandWithAugmentedPath } from "./shell-environment.js"; ++import { getExecutionCostSnapshot } from "./usage-meter.js"; + + type McpContent = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }; + export type ToolResponse = { +@@ -62,6 +69,100 @@ interface ApprovedShellCommand { + } + + const approvedCommandPrefix = "devspace-approved "; ++const runtimeCommandPrefix = "devspace-runtime"; ++ ++function textResponse(text: string, isError = false): ToolResponse { ++ return { ++ content: [{ type: "text", text }], ++ ...(isError ? { isError: true } : {}), ++ }; ++} ++ ++function jsonResponse(value: unknown): ToolResponse { ++ return textResponse(JSON.stringify(value, null, 2)); ++} ++ ++async function runBuiltinRuntimeCommand( ++ input: BashToolInput, ++ context: ToolContext, ++): Promise { ++ const rawCommand = String(input.command ?? "").trim(); ++ if ( ++ rawCommand !== runtimeCommandPrefix ++ && !rawCommand.startsWith(`${runtimeCommandPrefix} `) ++ ) return undefined; ++ ++ if ( ++ rawCommand === runtimeCommandPrefix ++ || rawCommand === `${runtimeCommandPrefix} help` ++ ) { ++ return textResponse([ ++ "Built-in DevSpace runtime commands:", ++ " devspace-runtime diagnose [--github] [command ...]", ++ " devspace-runtime smoke", ++ " devspace-runtime costs", ++ " devspace-runtime finder ", ++ ].join("\n")); ++ } ++ ++ if (rawCommand === `${runtimeCommandPrefix} costs`) { ++ return jsonResponse(getExecutionCostSnapshot()); ++ } ++ ++ if (rawCommand === `${runtimeCommandPrefix} smoke`) { ++ const result = await runCompatibilitySmoke(context.root); ++ return { ++ ...jsonResponse(result), ++ ...(result.status === "failed" ? { isError: true } : {}), ++ }; ++ } ++ ++ if ( ++ rawCommand === `${runtimeCommandPrefix} diagnose` ++ || rawCommand.startsWith(`${runtimeCommandPrefix} diagnose `) ++ ) { ++ const args = rawCommand ++ .slice(`${runtimeCommandPrefix} diagnose`.length) ++ .trim() ++ .split(/\s+/u) ++ .filter(Boolean); ++ const checkGitHubAuth = args.includes("--github"); ++ const commands = args ++ .filter((arg) => arg !== "--github") ++ .filter((arg) => /^[A-Za-z0-9._+-]+$/u.test(arg)) ++ .slice(0, 20); ++ const result = await diagnoseRuntime({ ++ workspaceRoot: context.root, ++ commands: commands.length > 0 ? commands : undefined, ++ checkGitHubAuth, ++ }); ++ return jsonResponse(result); ++ } ++ ++ if (rawCommand.startsWith(`${runtimeCommandPrefix} finder `)) { ++ const rawPath = rawCommand ++ .slice(`${runtimeCommandPrefix} finder `.length) ++ .trim(); ++ const requestedPath = ( ++ (rawPath.startsWith("\"") && rawPath.endsWith("\"")) ++ || (rawPath.startsWith("'") && rawPath.endsWith("'")) ++ ) ? rawPath.slice(1, -1) : rawPath; ++ if (!requestedPath) { ++ return textResponse("Finder path is required.", true); ++ } ++ try { ++ const absolutePath = resolveAllowedPath(requestedPath, context.cwd, [context.root]); ++ return jsonResponse(await openPathInFinder(absolutePath)); ++ } catch (error) { ++ return textResponse(error instanceof Error ? error.message : String(error), true); ++ } ++ } ++ ++ return textResponse( ++ "Unknown devspace-runtime command. Run `devspace-runtime help`.", ++ true, ++ ); ++} + function approvedCommandsPath(): string { + return resolve(expandHomePath( + process.env.DEVSPACE_APPROVED_SHELL_COMMANDS_FILE +@@ -200,6 +301,9 @@ export async function listDirectoryTool(input: LsToolInput, context: ToolContext + } + + export async function runShellTool(input: BashToolInput, context: ToolContext): Promise { ++ const builtin = await runBuiltinRuntimeCommand(input, context); ++ if (builtin) return builtin; ++ + let approved: { input: BashToolInput; cwd: string }; + try { + approved = await resolveApprovedShellAlias(input, context); +@@ -214,7 +318,7 @@ export async function runShellTool(input: BashToolInput, context: ToolContext): + : Math.min(approved.input.timeout, 300); + + return runTool((params) => tool.execute("run_shell", params), { +- command: approved.input.command, ++ command: commandWithAugmentedPath(approved.input.command), + timeout, + }, context); + } +diff --git a/src/register-v11-tools.ts b/src/register-v11-tools.ts +index 9730a8cf001b2a4df164db2245309690bbd838c5..62352611caa7bc08016e7591da243fcd7fba0aac 100644 +--- a/src/register-v11-tools.ts ++++ b/src/register-v11-tools.ts +@@ -11,6 +11,7 @@ import { + import { runDesignAudit } from "./design-audit.js"; + import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; + import { matchWorkspaceSkills } from "./skill-matcher.js"; ++import { openPathInFinder } from "./runtime-operations.js"; + import type { Workspace, WorkspaceRegistry } from "./workspaces.js"; + + const metricsSchema = z.object({ +@@ -36,6 +37,7 @@ export function registerV11Tools( + localAgentProviders: LocalAgentProviderAvailability[]; + }, + ): void { ++ registerFinderAppTool(server, input); + const enabledTools = new Set(enabledV11ToolNames(input.config)); + if (enabledTools.has("match_skills")) { + registerAppTool( +@@ -138,6 +140,55 @@ export function registerV11Tools( + } + } + ++function registerFinderAppTool( ++ server: McpServer, ++ input: { ++ config: ServerConfig; ++ workspaces: WorkspaceRegistry; ++ localAgentProviders: LocalAgentProviderAvailability[]; ++ }, ++): void { ++ registerAppTool( ++ server, ++ "open_in_finder", ++ { ++ title: "Open in Finder", ++ description: "Open a workspace-scoped local path in macOS Finder after validating that it remains inside the open workspace. Files are revealed; directories are opened. This Tool is callable only by the MCP App UI.", ++ inputSchema: { ++ workspaceId: z.string(), ++ path: z.string().min(1).max(4_000), ++ }, ++ outputSchema: { ++ status: z.enum(["opened", "unsupported"]), ++ path: z.string(), ++ kind: z.enum(["file", "directory"]), ++ }, ++ _meta: { ++ ui: { ++ visibility: ["app"], ++ }, ++ }, ++ annotations: { ++ readOnlyHint: true, ++ destructiveHint: false, ++ idempotentHint: true, ++ openWorldHint: false, ++ }, ++ }, ++ async ({ workspaceId, path }) => { ++ const workspace = input.workspaces.getWorkspace(workspaceId); ++ const absolutePath = input.workspaces.resolvePath(workspace, path); ++ const result = await openPathInFinder(absolutePath); ++ return toolResult( ++ result, ++ result.status === "opened" ++ ? `Opened ${result.kind} in Finder.` ++ : "Finder integration is only available on macOS.", ++ ); ++ }, ++ ); ++} ++ + export function enabledV11ToolNames(config: ServerConfig): string[] { + return [ + config.skillMatcher && config.skillsEnabled ? "match_skills" : undefined, +@@ -328,9 +379,9 @@ function inspectionContext( + }; + } + +-function toolResult>(structuredContent: T, summary: string) { ++function toolResult(structuredContent: T, summary: string) { + return { + content: [{ type: "text" as const, text: summary.slice(0, 1_000) }], +- structuredContent, ++ structuredContent: structuredContent as Record, + }; + } +diff --git a/src/server.ts b/src/server.ts +index 93edc39c4f29748294b76bf28d26ef716b06d762..f115360066853b256b8becea8a9fa65e6ed84cf5 100644 +--- a/src/server.ts ++++ b/src/server.ts +@@ -336,10 +336,22 @@ function logFailedToolResponse( + content: ToolContent[], + startedAt: number, + ): void { ++ const durationMs = Math.round(performance.now() - startedAt); ++ const outputChars = textContentChars(content); ++ recordObservedToolUsage({ ++ tool: fields.tool, ++ workspaceId: fields.workspaceId, ++ path: fields.path, ++ observedChars: outputChars, ++ savedChars: 0, ++ outputChars, ++ durationMs, ++ error: true, ++ }); + logToolCall(config, { + ...fields, + success: false, +- durationMs: Math.round(performance.now() - startedAt), ++ durationMs, + error: toolErrorPreview(content), + }); + } +@@ -1067,6 +1079,9 @@ function createMcpServer( + path: input.path, + observedChars, + savedChars, ++ inputChars: String(input.path).length, ++ outputChars: observedChars, ++ durationMs: Math.round(performance.now() - startedAt), + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { +@@ -1149,6 +1164,9 @@ function createMcpServer( + path: input.path, + observedChars: input.content.length + textContentChars(response.content), + savedChars: 0, ++ inputChars: input.content.length, ++ outputChars: textContentChars(response.content), ++ durationMs: Math.round(performance.now() - startedAt), + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { +@@ -1250,6 +1268,9 @@ function createMcpServer( + path: input.path, + observedChars, + savedChars, ++ inputChars: editInputChars(input.edits), ++ outputChars: textContentChars(editContent), ++ durationMs: Math.round(performance.now() - startedAt), + }); + const content = appendUsageToContent(editContent, usage, config.usageContent); + logToolCall(config, { +@@ -1472,6 +1493,12 @@ function createMcpServer( + + String(input.include ?? "").length + + textContentChars(response.content), + savedChars: 0, ++ inputChars: ++ String(input.pattern ?? "").length ++ + String(input.path ?? "").length ++ + String(input.include ?? "").length, ++ outputChars: textContentChars(response.content), ++ durationMs: Math.round(performance.now() - startedAt), + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { +@@ -1555,6 +1582,9 @@ function createMcpServer( + + String(input.path ?? "").length + + textContentChars(response.content), + savedChars: 0, ++ inputChars: String(input.pattern ?? "").length + String(input.path ?? "").length, ++ outputChars: textContentChars(response.content), ++ durationMs: Math.round(performance.now() - startedAt), + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { +@@ -1631,6 +1661,9 @@ function createMcpServer( + path: input.path, + observedChars: String(input.path ?? "").length + textContentChars(response.content), + savedChars: 0, ++ inputChars: String(input.path ?? "").length, ++ outputChars: textContentChars(response.content), ++ durationMs: Math.round(performance.now() - startedAt), + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { +@@ -1668,8 +1701,8 @@ function createMcpServer( + { + title: "Bash", + description: config.toolMode !== "full" +- ? `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.` +- : `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.`, ++ ? `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, search, file discovery, and directory inspection. In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled; use command-line tools such as grep, rg, find, ls, and tree for those read-only inspection actions. Built-in commands are available without adding more MCP Tools: devspace-runtime diagnose [--github] [command ...], devspace-runtime smoke, devspace-runtime costs, and devspace-runtime finder for an explicit user request. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read} for direct file reads. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.` ++ : `Run a shell command inside an open workspace. Use only for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Built-in commands are available without adding more MCP Tools: devspace-runtime diagnose [--github] [command ...], devspace-runtime smoke, devspace-runtime costs, and devspace-runtime finder for an explicit user request. Do not use ${toolNames.shell} to create or modify files. Do not use shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or generated scripts to write project files; use ${toolNames.edit} for targeted changes and ${toolNames.write} for new files or full rewrites. Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection. Call open_workspace first and pass workspaceId. This is powerful local execution and should only be exposed behind strong authentication.`, + inputSchema: { + workspaceId: z + .string() +@@ -1677,7 +1710,7 @@ function createMcpServer( + command: z + .string() + .describe( +- `Shell command to run. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes.`, ++ `Shell command to run. Use devspace-runtime diagnose, smoke, or costs for built-in diagnostics; use devspace-runtime finder only on explicit request. Must not create or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes.`, + ), + workingDirectory: z + .string() +@@ -1731,6 +1764,9 @@ function createMcpServer( + path: workingDirectory ?? ".", + observedChars: input.command.length + textContentChars(response.content), + savedChars: 0, ++ inputChars: input.command.length, ++ outputChars: textContentChars(response.content), ++ durationMs: Math.round(performance.now() - startedAt), + }); + const content = appendUsageToContent(response.content, usage, config.usageContent); + logToolCall(config, { diff --git a/compatibility-kit/openai-model-compatibility-2026-07/patches/0006-finder-app-action.patch b/compatibility-kit/openai-model-compatibility-2026-07/patches/0006-finder-app-action.patch new file mode 100644 index 00000000..171bb90b --- /dev/null +++ b/compatibility-kit/openai-model-compatibility-2026-07/patches/0006-finder-app-action.patch @@ -0,0 +1,141 @@ +diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css +index 28ba6bf5450997dedd96e5aee29ec1b5863a8ab4..fa8ecce3b2dfc4f2df9548aca7e8a308a512d430 100644 +--- a/src/ui/workspace-app.css ++++ b/src/ui/workspace-app.css +@@ -105,6 +105,59 @@ body { + white-space: nowrap; + } + ++.path-action-row { ++ padding: 0 14px 10px 64px; ++} ++ ++.path-link { ++ display: grid; ++ grid-template-columns: minmax(0, 1fr) auto; ++ align-items: center; ++ gap: 12px; ++ width: 100%; ++ min-height: 31px; ++ padding: 5px 8px 5px 10px; ++ border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 72%, transparent); ++ border-radius: 7px; ++ background: color-mix(in srgb, var(--color-background-primary, #17181c) 38%, transparent); ++ color: var(--color-text-secondary, #d6d6dc); ++ cursor: pointer; ++ text-align: left; ++} ++ ++.path-link:hover:not(:disabled), ++.path-link:focus-visible { ++ border-color: color-mix(in srgb, var(--color-link, #78a9ff) 58%, var(--color-border-primary, #3a3a40)); ++ color: var(--color-link, #78a9ff); ++ outline: none; ++} ++ ++.path-link:disabled { ++ cursor: default; ++ opacity: 0.78; ++} ++ ++.path-link-text { ++ overflow: hidden; ++ font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); ++ font-size: var(--font-text-xs-size, 12px); ++ text-decoration: underline; ++ text-decoration-color: color-mix(in srgb, currentColor 45%, transparent); ++ text-overflow: ellipsis; ++ white-space: nowrap; ++} ++ ++.path-link-action { ++ color: var(--color-text-tertiary, #a3a3aa); ++ font-size: var(--font-text-xs-size, 12px); ++ white-space: nowrap; ++} ++ ++.path-link:hover:not(:disabled) .path-link-action, ++.path-link:focus-visible .path-link-action { ++ color: inherit; ++} ++ + .stats { + display: inline-flex; + gap: 5px; +diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx +index edbb620fec3ab20a81603d15e45075e33af41b4d..d558621e7a290a2d8cbf5ea903f70cb11a83e513 100644 +--- a/src/ui/workspace-app.tsx ++++ b/src/ui/workspace-app.tsx +@@ -200,6 +200,8 @@ function render(): void { + renderChevron(expanded, expandable), + ); + section.append(button); ++ const pathAction = renderPathAction(card); ++ if (pathAction) section.append(pathAction); + + if (expanded) { + const body = element("div", { className: "tool-body" }); +@@ -212,6 +214,63 @@ function render(): void { + renderPayloadIfNeeded(); + } + ++function renderPathAction(card: ToolResultCard): HTMLElement | null { ++ const displayPath = card.path ?? card.root; ++ const toolPath = card.path ?? (card.root ? "." : undefined); ++ if (!app || !card.workspaceId || !displayPath || !toolPath) return null; ++ ++ const row = element("div", { className: "path-action-row" }); ++ const button = element("button", { ++ className: "path-link", ++ type: "button", ++ title: `Open in Finder: ${displayPath}`, ++ }); ++ button.setAttribute("aria-label", `Open in Finder: ${displayPath}`); ++ const pathText = element("span", { ++ className: "path-link-text", ++ text: displayPath, ++ }); ++ const actionText = element("span", { ++ className: "path-link-action", ++ text: "Open in Finder", ++ }); ++ button.append(pathText, actionText); ++ ++ button.addEventListener("click", async () => { ++ if (!app || button.disabled) return; ++ button.disabled = true; ++ actionText.textContent = "Opening…"; ++ try { ++ const result = await app.callServerTool({ ++ name: "open_in_finder", ++ arguments: { ++ workspaceId: card.workspaceId, ++ path: toolPath, ++ }, ++ }); ++ const structured = getStructuredContent<{ status?: string }>(result); ++ if (result.isError || structured?.status === "unsupported") { ++ actionText.textContent = structured?.status === "unsupported" ++ ? "macOS only" ++ : "Unable to open"; ++ button.disabled = false; ++ return; ++ } ++ actionText.textContent = "Opened in Finder"; ++ window.setTimeout(() => { ++ actionText.textContent = "Open in Finder"; ++ button.disabled = false; ++ }, 1_800); ++ } catch (openError) { ++ actionText.textContent = openError instanceof Error ? "Unable to open" : "Error"; ++ button.disabled = false; ++ } ++ }); ++ ++ row.append(button); ++ return row; ++} ++ + function renderEmpty(message: string, tone: "muted" | "error" = "muted"): void { + const main = element("main", { className: "shell" }); + main.append(element("section", { className: `empty ${tone}`, text: message })); diff --git a/src/shell-environment.test.ts b/src/shell-environment.test.ts index 601803b3..0ddadb2b 100644 --- a/src/shell-environment.test.ts +++ b/src/shell-environment.test.ts @@ -10,14 +10,14 @@ const existing = ["/usr/bin", "/bin"].join(delimiter); const existingDirs = new Set([ "/opt/homebrew/bin", "/usr/local/bin", - "/Users/test/.local/bin", + "/test-home/.local/bin", "/usr/bin", "/bin", ]); const info = shellPathInfo( { PATH: existing }, "darwin", - "/Users/test", + "/test-home", (path) => existingDirs.has(path), ); assert.deepEqual(info.entries, [ @@ -25,12 +25,12 @@ assert.deepEqual(info.entries, [ "/bin", "/opt/homebrew/bin", "/usr/local/bin", - "/Users/test/.local/bin", + "/test-home/.local/bin", ]); assert.deepEqual(info.addedEntries, [ "/opt/homebrew/bin", "/usr/local/bin", - "/Users/test/.local/bin", + "/test-home/.local/bin", ]); assert.match(commandWithAugmentedPath("gh auth status", { PATH: existing }, "darwin"), /^export PATH='/); assert.equal(commandWithAugmentedPath("dir", { PATH: existing }, "win32"), "dir");