mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0820ee14f1 | ||
|
|
49ebc26cdb | ||
|
|
6bad956c8f | ||
|
|
233b309fd9 | ||
|
|
ae4123be10 | ||
|
|
6b0ff42748 | ||
|
|
627dc21736 | ||
|
|
7eebee2de0 | ||
|
|
ff2fc72581 | ||
|
|
cdd7071885 | ||
|
|
30cba9550e | ||
|
|
1318372e81 | ||
|
|
185990f46b | ||
|
|
508cd524a8 |
@@ -0,0 +1,41 @@
|
||||
name: Fix Nix hash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
fix-nix-hash:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Fix lockfile and update hash
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/package.nix
|
||||
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||
git push
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Nix Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'nix/**'
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/server/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/cli/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'nix/**'
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/server/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/cli/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Verify lockfile is complete
|
||||
# npm silently omits resolved/integrity fields in workspace monorepos.
|
||||
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
|
||||
run: |
|
||||
node scripts/fix-lockfile.mjs package-lock.json
|
||||
git diff --exit-code package-lock.json || {
|
||||
echo "ERROR: package-lock.json has missing resolved/integrity fields."
|
||||
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
|
||||
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Check npmDepsHash is up to date
|
||||
run: ./scripts/update-nix.sh --check
|
||||
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.34 - 2026-03-25
|
||||
|
||||
### Added
|
||||
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
|
||||
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
|
||||
- Added a redesigned command autocomplete with a detail card and dropdown styling.
|
||||
- Linked Android download surfaces to the Google Play Store.
|
||||
|
||||
### Improved
|
||||
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
|
||||
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
|
||||
- Restored per-provider form preferences and removed the Auto model fallback.
|
||||
- Improved Codex activity logs with clearer tool-call summaries.
|
||||
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
|
||||
- Improved chat transcript readability.
|
||||
|
||||
### Fixed
|
||||
- Fixed `paseo send --no-wait` not taking effect.
|
||||
- Fixed stale abort results contaminating replacement turns after an interrupt.
|
||||
- Fixed Claude interrupt handling and autonomous wake reliability.
|
||||
- Fixed nested Claude Code session detection and provider availability checks.
|
||||
- Fixed agent input focus scoping across panels.
|
||||
- Fixed terminal snapshot ordering when subscribing.
|
||||
- Fixed `chat read --since` to accept message IDs.
|
||||
- Fixed keyboard pane focus syncing with the active panel.
|
||||
- Fixed assistant text selection on web.
|
||||
- Fixed archived-agent notifications still appearing in chat rooms.
|
||||
- Fixed the attach-images button interaction in the message composer.
|
||||
- Pruned wrong-platform native binaries from Electron desktop builds.
|
||||
|
||||
## 0.1.33 - 2026-03-23
|
||||
|
||||
### Fixed
|
||||
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772963539,
|
||||
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
}:
|
||||
let
|
||||
supportedSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
pkgsFor = system: import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
nixosModules.paseo =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
imports = [ ./nix/module.nix ];
|
||||
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
};
|
||||
|
||||
devShells = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.nodejs_22
|
||||
pkgs.python3
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.paseo;
|
||||
in
|
||||
{
|
||||
options.services.paseo = {
|
||||
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||
|
||||
package = lib.mkPackageOption pkgs "paseo" { };
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "paseo";
|
||||
description = "User account under which Paseo runs.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "paseo";
|
||||
description = "Group under which Paseo runs.";
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default =
|
||||
if cfg.user == "paseo"
|
||||
then "/var/lib/paseo"
|
||||
else "/home/${cfg.user}/.paseo";
|
||||
defaultText = lib.literalExpression ''
|
||||
if cfg.user == "paseo"
|
||||
then "/var/lib/paseo"
|
||||
else "/home/''${cfg.user}/.paseo"
|
||||
'';
|
||||
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 6767;
|
||||
description = "Port for the Paseo daemon to listen on.";
|
||||
};
|
||||
|
||||
listenAddress = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address for the Paseo daemon to bind to.";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||
};
|
||||
|
||||
allowedHosts = lib.mkOption {
|
||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||
default = [ ];
|
||||
example = [ ".example.com" "myhost.local" ];
|
||||
description = ''
|
||||
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||
Localhost and IP addresses are always allowed by default.
|
||||
|
||||
Use a leading dot to match a domain and all its subdomains
|
||||
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
|
||||
|
||||
Set to `true` to allow any host (not recommended).
|
||||
'';
|
||||
};
|
||||
|
||||
relay = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
};
|
||||
};
|
||||
|
||||
inheritUserEnvironment = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = cfg.user != "paseo";
|
||||
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
|
||||
description = ''
|
||||
Whether to include the user's profile PATH in the service environment.
|
||||
|
||||
When Paseo runs as a real user (not the default system user), AI agents
|
||||
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||
NixOS profile and system paths so agents can use them without manually
|
||||
setting PATH.
|
||||
|
||||
Enabled by default when `user` is set to a non-default value.
|
||||
'';
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
}
|
||||
'';
|
||||
description = "Extra environment variables for the Paseo daemon.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.paseo = {
|
||||
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||
# includes /run/current-system/sw/bin which is a superset of those.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||
PASEO_ALLOWED_HOSTS = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
|
||||
ExecStart =
|
||||
"${cfg.package}/bin/paseo-server"
|
||||
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
|
||||
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
|
||||
KillSignal = "SIGTERM";
|
||||
TimeoutStopSec = 15;
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "paseo";
|
||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./..;
|
||||
filter = path: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf path;
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
in
|
||||
# Exclude non-daemon workspace contents (keep package.json for workspace resolution)
|
||||
!(lib.hasPrefix "/packages/app/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/assets" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
&& !(lib.hasPrefix "/packages/website/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/website/public" relPath)
|
||||
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
|
||||
# Exclude test fixtures and debug files
|
||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||
&& baseName != "node_modules"
|
||||
&& baseName != ".git"
|
||||
&& baseName != ".paseo"
|
||||
&& baseName != ".DS_Store";
|
||||
};
|
||||
|
||||
nodejs = nodejs_22;
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-qRknrMt2VBCCRGGedyTrPEBs3u+Z8lmDZuuvX7mE2Cg=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
# We manually rebuild only node-pty in buildPhase.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty compilation)
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
libuv
|
||||
];
|
||||
|
||||
# Don't use the default npm build hook — we need a custom build sequence
|
||||
dontNpmBuild = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Rebuild only node-pty (native addon for terminal emulation).
|
||||
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||
# degrade when unavailable.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all daemon packages in dependency order (defined in package.json)
|
||||
npm run build:daemon
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib/paseo
|
||||
|
||||
# Copy root package metadata
|
||||
cp package.json $out/lib/paseo/
|
||||
|
||||
# Copy node_modules (preserving workspace symlinks)
|
||||
cp -a node_modules $out/lib/paseo/
|
||||
|
||||
# Auto-detect which @getpaseo/* packages were built by build:daemon
|
||||
# (they'll have a dist/ directory). Copy those and remove the rest.
|
||||
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
|
||||
name=$(basename "$link")
|
||||
if [ -d "packages/$name/dist" ]; then
|
||||
mkdir -p "$out/lib/paseo/packages/$name"
|
||||
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
|
||||
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
|
||||
if [ -d "packages/$name/node_modules" ]; then
|
||||
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
|
||||
fi
|
||||
else
|
||||
rm -f "$link"
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy CLI bin entry
|
||||
mkdir -p $out/lib/paseo/packages/cli/bin
|
||||
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
|
||||
|
||||
# Copy extra server files referenced at runtime
|
||||
for f in agent-prompt.md .env.example; do
|
||||
if [ -f packages/server/$f ]; then
|
||||
cp packages/server/$f $out/lib/paseo/packages/server/
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy server scripts (daemon-runner, supervisor) needed by CLI
|
||||
if [ -d packages/server/dist/scripts ]; then
|
||||
mkdir -p $out/lib/paseo/packages/server/dist/scripts
|
||||
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
|
||||
fi
|
||||
|
||||
# Create wrapper for the server entry point (for systemd / direct use)
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||
--set NODE_ENV production
|
||||
|
||||
# Create wrapper for the CLI
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
|
||||
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
|
||||
--set NODE_PATH "$out/lib/paseo/node_modules"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
}
|
||||
Generated
+94
-44
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34842,16 +34842,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.33",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.34",
|
||||
"@getpaseo/highlight": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -34942,7 +34942,9 @@
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
|
||||
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q=="
|
||||
},
|
||||
"packages/app/node_modules/react-native-nitro-modules": {
|
||||
"version": "0.33.8",
|
||||
@@ -34959,15 +34961,17 @@
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/relay": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -34993,21 +34997,25 @@
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="
|
||||
},
|
||||
"packages/cli/node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/cli": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
@@ -35040,7 +35048,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35241,7 +35249,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35267,7 +35275,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35283,13 +35291,13 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/relay": "0.1.33",
|
||||
"@getpaseo/highlight": "0.1.34",
|
||||
"@getpaseo/relay": "0.1.34",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
@@ -35351,7 +35359,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
|
||||
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA=="
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
@@ -35365,7 +35375,9 @@
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
|
||||
"version": "5.1.0",
|
||||
@@ -35405,7 +35417,9 @@
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="
|
||||
},
|
||||
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
@@ -35422,7 +35436,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="
|
||||
},
|
||||
"packages/server/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
@@ -35436,7 +35452,9 @@
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="
|
||||
},
|
||||
"packages/server/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
@@ -35446,7 +35464,9 @@
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="
|
||||
},
|
||||
"packages/server/node_modules/body-parser": {
|
||||
"version": "2.2.0",
|
||||
@@ -35464,7 +35484,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="
|
||||
},
|
||||
"packages/server/node_modules/content-disposition": {
|
||||
"version": "1.0.0",
|
||||
@@ -35474,14 +35496,18 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
|
||||
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="
|
||||
},
|
||||
"packages/server/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="
|
||||
},
|
||||
"packages/server/node_modules/finalhandler": {
|
||||
"version": "2.1.0",
|
||||
@@ -35496,21 +35522,27 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="
|
||||
},
|
||||
"packages/server/node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="
|
||||
},
|
||||
"packages/server/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="
|
||||
},
|
||||
"packages/server/node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
@@ -35520,7 +35552,9 @@
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="
|
||||
},
|
||||
"packages/server/node_modules/mime-types": {
|
||||
"version": "3.0.1",
|
||||
@@ -35530,14 +35564,18 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="
|
||||
},
|
||||
"packages/server/node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="
|
||||
},
|
||||
"packages/server/node_modules/raw-body": {
|
||||
"version": "3.0.2",
|
||||
@@ -35588,7 +35626,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="
|
||||
},
|
||||
"packages/server/node_modules/serve-static": {
|
||||
"version": "2.2.0",
|
||||
@@ -35601,7 +35641,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="
|
||||
},
|
||||
"packages/server/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
@@ -35614,7 +35656,9 @@
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="
|
||||
},
|
||||
"packages/server/node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
@@ -35626,18 +35670,22 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="
|
||||
},
|
||||
"packages/server/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
@@ -35667,7 +35715,9 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
|
||||
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ=="
|
||||
},
|
||||
"packages/website/node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.33",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.34",
|
||||
"@getpaseo/highlight": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -950,18 +950,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<View style={styles.leftButtonGroup}>
|
||||
{onPickImages && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onPickImages}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityRole="button"
|
||||
style={({ hovered }) => [
|
||||
styles.attachButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
onPress={onPickImages}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Attach images"
|
||||
accessibilityRole="button"
|
||||
style={({ hovered }) => [
|
||||
styles.attachButton,
|
||||
hovered && styles.iconButtonHovered,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Attach images</Text>
|
||||
@@ -1203,7 +1205,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
leftButtonGroup: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
rightButtonGroup: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -31,11 +31,11 @@ import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
} from "lucide-react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
@@ -392,15 +392,15 @@ function NewWorktreeButton({
|
||||
}}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new worktree for ${displayName}`}
|
||||
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
loading ? (
|
||||
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<Plus
|
||||
size={14}
|
||||
<FolderPlus
|
||||
size={15}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
@@ -411,7 +411,7 @@ function NewWorktreeButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.projectActionTooltipRow}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
<Text style={styles.projectActionTooltipText}>New workspace</Text>
|
||||
{showShortcutHint && newWorktreeKeys ? (
|
||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/relay": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.33",
|
||||
"@getpaseo/server": "0.1.33",
|
||||
"@getpaseo/cli": "0.1.34",
|
||||
"@getpaseo/server": "0.1.34",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
# Voice Assistant System Prompt
|
||||
|
||||
## 1. Core Voice Rules (NON-NEGOTIABLE)
|
||||
|
||||
### Voice Context
|
||||
|
||||
You are a **voice-controlled** assistant. The user speaks to you via phone and hears your responses via TTS.
|
||||
|
||||
### Voice Message Envelope
|
||||
|
||||
Some user utterances will be wrapped in an XML tag like:
|
||||
|
||||
`<voice-transcription focused-agent-id="...">...</voice-transcription>`
|
||||
|
||||
- Treat the inner text as what the user said (STT output).
|
||||
- `focused-agent-id` is **context only**: it means which agent screen the user is currently looking at. It does *not* mean all actions must target that agent, but it is a strong hint for ambiguous commands like “stop it” or “cancel the agent”.
|
||||
|
||||
**Critical constraints:**
|
||||
|
||||
- User typically codes from their **phone** using voice
|
||||
- **No visual feedback** - they can't see command output unless at laptop
|
||||
- Input comes through **speech-to-text (STT)** which makes errors
|
||||
- Output is spoken via **text-to-speech (TTS)**
|
||||
- User may be mobile, away from desk, multitasking
|
||||
|
||||
### Communication Rules
|
||||
|
||||
**1-3 sentences maximum per response. Always.**
|
||||
|
||||
- **Plain speech only** - NO markdown (no bullets, bold, lists, headers)
|
||||
- **Progressive disclosure** - answer what's asked, let user ask for more
|
||||
- **Start high-level** - give the gist, not every detail
|
||||
- **Natural pauses** - leave room for user to respond or redirect
|
||||
|
||||
**Good example:**
|
||||
|
||||
```
|
||||
User: "List my agents"
|
||||
You: "You have two agents. One working on authentication in the web app, another running tests in Faro."
|
||||
|
||||
User: "How's the auth agent doing?"
|
||||
You: "It finished adding the login flow and is waiting for your approval on the database migration."
|
||||
```
|
||||
|
||||
**Bad example:**
|
||||
|
||||
```
|
||||
User: "List my agents"
|
||||
You: "You have 2 agents: 1. **auth-agent** - Working on authentication 2. **test-agent** - Running tests..."
|
||||
```
|
||||
|
||||
### Handling STT Errors
|
||||
|
||||
Speech-to-text makes mistakes. Fix them silently using context.
|
||||
|
||||
**Common errors:**
|
||||
|
||||
- Homophones: "list" → "missed", "code" → "load"
|
||||
- Project names: "faro" → "pharaoh", "mcp" → "empty"
|
||||
- Technical terms: "typescript" → "type script", "npm install" → "NPM in style"
|
||||
|
||||
**How to handle:**
|
||||
|
||||
1. Use context to fix obvious mistakes silently
|
||||
2. Ask for clarification only when truly ambiguous
|
||||
3. Never lecture about the error - just handle it
|
||||
4. When clarifying, be brief: "Which project? Web, agent, or MCP?"
|
||||
|
||||
**Examples:**
|
||||
|
||||
- User: "Run empty install" → Interpret as "Run npm install"
|
||||
- User: "Check the agent" → If only one agent, check that one; if multiple, ask which
|
||||
|
||||
### Immediate Silence Protocol
|
||||
|
||||
If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**:
|
||||
|
||||
- "I'm not talking to you"
|
||||
- "Shut up" / "Be quiet" / "Stop talking"
|
||||
- "Not you"
|
||||
|
||||
**Response: Complete silence. No acknowledgment. Wait for user to address you again.**
|
||||
|
||||
## 2. Delegation Pattern
|
||||
|
||||
### Core Rule: Always Work Through Coding Agents
|
||||
|
||||
Direct command-line tools (`execute_command`, `send_text_to_command`, `kill_command`, etc.) are disabled. Every change to files, git, builds, or tests must go through a coding agent. Your job is to decide when to reuse an existing agent versus spinning up a new one, then route follow-ups appropriately.
|
||||
|
||||
### Safe Operations (Execute Immediately)
|
||||
|
||||
These orchestration tools only read or summarize state:
|
||||
|
||||
- `list_agents()` – discover who exists before delegating
|
||||
- `get_agent_activity()` – pull the curated activity/readout for a specific agent
|
||||
- `wait_for_agent()` – block until an agent requests permission or completes the current run
|
||||
|
||||
Call them without asking when context requires it. Never fabricate their output.
|
||||
|
||||
### Delegated Operations (Announce + Execute)
|
||||
|
||||
- `create_agent()` – Ask for confirmation unless the user already issued a clear imperative (“spin up a new planner”), then acknowledge and create immediately.
|
||||
- `send_agent_prompt()` – Route the user’s request to the focused agent. If the user explicitly names a different agent, switch focus first, then send the prompt.
|
||||
- `set_agent_mode()`, `cancel_agent()`, `kill_agent()` – Only when the user directs you to or the agent is stuck. Confirm destructive actions.
|
||||
|
||||
After delegating, monitor via `wait_for_agent()` or `get_agent_activity()` and translate the relevant summary back to the user.
|
||||
|
||||
### When to Ask vs Act
|
||||
|
||||
Ask only when the routing decision is truly ambiguous. Otherwise:
|
||||
|
||||
- Default to the most recently addressed agent.
|
||||
- If the user mentions a new agent (“spin up planner”, “Codex, pick this up”), treat it as both a creation/selection and a focus switch.
|
||||
- Use activity context to disambiguate references (“keep going on the migration” → whichever agent was migrating).
|
||||
|
||||
### Tool Results Reporting
|
||||
|
||||
After any agent-facing tool call, verbally report the key result in one sentence: who acted, what happened, and whether more work is pending. Example: “Agent Planner says the test plan is drafted and still running validations.” Progressive disclosure still applies—offer deeper details only when asked.
|
||||
|
||||
## 4. Agent Integrations
|
||||
|
||||
### Your Role: Orchestrator
|
||||
|
||||
You orchestrate work. Agents execute.
|
||||
|
||||
**First action when agent work is mentioned: Call `list_agents()`**
|
||||
|
||||
Load the agent list before any agent interaction. Always.
|
||||
|
||||
#### Focus Management
|
||||
|
||||
- Keep a lightweight "focus" pointer to the last agent the user explicitly addressed or implicitly referenced. Route follow-up utterances there unless the user names another agent.
|
||||
- Update focus whenever the user spins up a new agent (“create a planner for this”) or targets one by name. Treat that change as sticky until silence/irrelevant turns cause confidence to drop.
|
||||
- When confidence is low (long gap, conflicting references), briefly confirm: “Do you want Planner or Architect on this?”
|
||||
- Always narrate hand-offs: “Okay, handing that to Planner.”
|
||||
- Every time you speak on behalf of an agent, prefix with `Agent <name> says …` so the user always knows who just responded and can redirect explicitly.
|
||||
|
||||
**Confirm before destructive agent operations:**
|
||||
- Creating agents: "Create agent in [directory] for [task]?" (unless user already issued a direct imperative)
|
||||
- Killing agents: "Kill agent [id] working on [task]?"
|
||||
|
||||
**Delegate vs execute:**
|
||||
- Everything touching code, git, or shell runs through agents
|
||||
- Keep lightweight questions or summaries in-orchestrator when no action is needed
|
||||
- Active agent context → Send prompt to that agent (respect focus)
|
||||
|
||||
### Available Agents (Source of Truth)
|
||||
|
||||
We only have two coding agents. Do not call tools to discover them—treat this section as canonical. When you create or configure an agent, runtime validation will reject invalid combinations.
|
||||
|
||||
**Claude Code (`claude`)**
|
||||
- Default mode: `plan`
|
||||
- Alternate mode: `bypassPermissions`
|
||||
- Best for deliberative work. Start in `plan` when the user wants transparency, switch to `bypassPermissions` only with explicit approval for fast execution.
|
||||
|
||||
**Codex (`codex`)**
|
||||
- Default mode: `auto`
|
||||
- Other modes: `read-only`, `full-access`
|
||||
- Use `read-only` for safe inspection, `auto` for normal edit/run loops, and escalate to `full-access` only when the user authorizes unrestricted access.
|
||||
|
||||
### Creating Agents
|
||||
|
||||
**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in paseo”), acknowledge and create immediately; otherwise, ask.
|
||||
|
||||
```javascript
|
||||
// Claude Code with planning
|
||||
create_agent({
|
||||
cwd: "~/dev/paseo",
|
||||
agentType: "claude",
|
||||
initialPrompt: "add dark mode toggle to settings page",
|
||||
initialMode: "plan"
|
||||
})
|
||||
|
||||
// Codex for quick edits
|
||||
create_agent({
|
||||
cwd: "~/dev/paseo",
|
||||
agentType: "codex",
|
||||
initialPrompt: "clean up the logging",
|
||||
initialMode: "auto"
|
||||
})
|
||||
```
|
||||
|
||||
If the user omits `initialMode`, the defaults above apply. Invalid agentType/mode pairs will throw—just surface the error.
|
||||
|
||||
### Working with Agents
|
||||
|
||||
**Send prompts to agents:**
|
||||
|
||||
```javascript
|
||||
// Send task (non-blocking by default)
|
||||
send_agent_prompt({
|
||||
agentId: "abc123",
|
||||
prompt: "explain how authentication works"
|
||||
})
|
||||
// Returns immediately, agent processes in background
|
||||
|
||||
// Send task and wait for completion
|
||||
send_agent_prompt({
|
||||
agentId: "abc123",
|
||||
prompt: "fix the bug in auth.ts",
|
||||
maxWait: 60000 // Wait up to 60 seconds
|
||||
})
|
||||
|
||||
// Change mode and send prompt (Claude -> bypassPermissions, Codex -> full-access)
|
||||
send_agent_prompt({
|
||||
agentId: "abc123",
|
||||
prompt: "implement user registration",
|
||||
sessionMode: "bypassPermissions"
|
||||
})
|
||||
```
|
||||
|
||||
**Check agent status:**
|
||||
|
||||
```javascript
|
||||
// Get current status
|
||||
get_agent_status({ agentId: "abc123" })
|
||||
// Returns: { status: "processing", info: {...} }
|
||||
|
||||
// Get agent activity (curated, human-readable)
|
||||
get_agent_activity({
|
||||
agentId: "abc123",
|
||||
format: "curated" // Clean summary of what agent did
|
||||
})
|
||||
|
||||
// List all agents
|
||||
list_agents()
|
||||
// Returns: { agents: [{id, status, createdAt, ...}, ...] }
|
||||
```
|
||||
|
||||
**Control agents:**
|
||||
|
||||
```javascript
|
||||
// Change session mode (safe, no confirmation needed)
|
||||
set_agent_mode({
|
||||
agentId: "abc123",
|
||||
modeId: "plan"
|
||||
})
|
||||
|
||||
// Cancel current task (safe, no confirmation needed)
|
||||
cancel_agent({ agentId: "abc123" })
|
||||
|
||||
// Kill agent (REQUIRES confirmation first)
|
||||
kill_agent({ agentId: "abc123" })
|
||||
```
|
||||
|
||||
### Agent Workflow Pattern
|
||||
|
||||
```javascript
|
||||
// 1. Load agents first
|
||||
list_agents()
|
||||
|
||||
// 2. If creating new agent, confirm first
|
||||
// You: "Create agent in ~/dev/project for authentication?"
|
||||
// User: "yes"
|
||||
|
||||
// 3. Create with type + mode
|
||||
create_agent({
|
||||
cwd: "~/dev/project",
|
||||
agentType: "claude",
|
||||
initialPrompt: "add authentication",
|
||||
initialMode: "plan"
|
||||
})
|
||||
|
||||
// 4. Monitor or send follow-up tasks
|
||||
get_agent_activity({ agentId })
|
||||
send_agent_prompt({ agentId, prompt: "add tests" })
|
||||
```
|
||||
|
||||
## 5. Git & GitHub
|
||||
|
||||
### Git Worktree Utilities
|
||||
|
||||
Custom utilities for safe worktree management:
|
||||
|
||||
**create-worktree:**
|
||||
- Creates new git worktree with new branch
|
||||
- Example: `create-worktree "feature"` creates `~/dev/repo-feature`
|
||||
- Outputs WORKTREE_PATH for you to parse
|
||||
|
||||
**delete-worktree:**
|
||||
- Preserves the branch, only deletes directory
|
||||
- Safe to use - won't lose work
|
||||
- Run from within worktree directory
|
||||
|
||||
### GitHub CLI (gh)
|
||||
|
||||
Already authenticated. Use for:
|
||||
|
||||
- Creating PRs: `gh pr create`
|
||||
- Viewing PRs: `gh pr view`
|
||||
- Managing issues: `gh issue list`
|
||||
- Checking CI: `gh pr checks`
|
||||
|
||||
## 6. Projects & Context
|
||||
|
||||
### Project Locations
|
||||
|
||||
All projects in `~/dev`:
|
||||
|
||||
**paseo**
|
||||
- Location: `~/dev/paseo`
|
||||
- Packages: `voice-assistant`
|
||||
|
||||
**Faro** (Autonomous Competitive Intelligence)
|
||||
- Bare repo: `~/dev/faro`
|
||||
- Main checkout: `~/dev/faro/main`
|
||||
|
||||
**Blank.page** (Minimal browser text editor)
|
||||
- Location: `~/dev/blank.page/editor`
|
||||
|
||||
### Decision Rules
|
||||
|
||||
**Agent work mentioned?**
|
||||
1. Call `list_agents()` first
|
||||
2. Reuse existing agent if task relates to its work
|
||||
3. Only confirm new-agent creation when the request is ambiguous. Clear imperatives (“spin up a new planner agent”) should be acknowledged and executed immediately.
|
||||
|
||||
**Creating/killing agents?**
|
||||
- Ask: "Create agent in [dir] for [task]?" when intent isn’t explicit
|
||||
- Ask: "Kill agent [id]?"
|
||||
- Wait for "yes"
|
||||
|
||||
**Task routing:**
|
||||
- All coding tasks → Delegate to an agent
|
||||
- Active agent + related work → Delegate to that agent
|
||||
- If the user explicitly mentions another agent, switch focus before delegating
|
||||
|
||||
**Context tracking:**
|
||||
- Track active agents and their directories
|
||||
- Use conversation context to resolve ambiguity
|
||||
- Fix STT errors silently
|
||||
- Maintain a recency-based focus pointer and narrate any focus change out loud
|
||||
|
||||
### Core Reminders
|
||||
|
||||
- Call actual tools, never just describe
|
||||
- 1-3 sentences max per response
|
||||
- Always report agent/tool results verbally (preface with "Agent X says …" when relaying)
|
||||
- Default to action when context is clear
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -13,8 +13,7 @@
|
||||
"dist/scripts",
|
||||
"src/server/speech/providers/local/sherpa/assets",
|
||||
"README.md",
|
||||
".env.example",
|
||||
"agent-prompt.md"
|
||||
".env.example"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -64,8 +63,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.33",
|
||||
"@getpaseo/relay": "0.1.33",
|
||||
"@getpaseo/highlight": "0.1.34",
|
||||
"@getpaseo/relay": "0.1.34",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import type { AgentModelDefinition } from "../agent-sdk-types.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
function isBinaryInstalled(binary: string): boolean {
|
||||
@@ -15,6 +16,16 @@ function isBinaryInstalled(binary: string): boolean {
|
||||
const hasCodex = isBinaryInstalled("codex");
|
||||
const hasOpenCode = isBinaryInstalled("opencode");
|
||||
|
||||
function modelMatchesFamily(
|
||||
model: AgentModelDefinition,
|
||||
family: "sonnet" | "haiku",
|
||||
): boolean {
|
||||
const haystacks = [model.id, model.label, model.description ?? ""].map((value) =>
|
||||
value.toLowerCase(),
|
||||
);
|
||||
return haystacks.some((text) => text.includes(family));
|
||||
}
|
||||
|
||||
describe("provider model catalogs (e2e)", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
@@ -32,11 +43,8 @@ describe("provider model catalogs (e2e)", () => {
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.models.length).toBeGreaterThan(0);
|
||||
|
||||
const descriptions = result.models.map((model) =>
|
||||
`${model.label} ${model.description ?? ""}`.toLowerCase(),
|
||||
);
|
||||
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
|
||||
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
|
||||
expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
|
||||
expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
|
||||
}, 180_000);
|
||||
|
||||
test.runIf(hasCodex)(
|
||||
|
||||
+29
-6
@@ -336,11 +336,7 @@ describe("Agent event stream redesign — integration", () => {
|
||||
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
|
||||
|
||||
try {
|
||||
const { turnId } = await handle.session.startTurn(
|
||||
"write a very long essay about the history of computing",
|
||||
);
|
||||
|
||||
// Use a single subscription to avoid missing events between unsubscribe/resubscribe
|
||||
let turnId: string | null = null;
|
||||
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
|
||||
const collected: AgentStreamEvent[] = [];
|
||||
let interrupted = false;
|
||||
@@ -352,10 +348,14 @@ describe("Agent event stream redesign — integration", () => {
|
||||
|
||||
const unsubscribe = handle.session.subscribe((event) => {
|
||||
collected.push(event);
|
||||
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
|
||||
turnId = event.turnId;
|
||||
}
|
||||
|
||||
// Once we see turn_started, fire the interrupt
|
||||
if (
|
||||
!interrupted &&
|
||||
turnId &&
|
||||
event.type === "turn_started" &&
|
||||
hasTurnId(event) &&
|
||||
event.turnId === turnId
|
||||
@@ -365,14 +365,37 @@ describe("Agent event stream redesign — integration", () => {
|
||||
}
|
||||
|
||||
// Resolve when we get a terminal event for this turn
|
||||
if (isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
|
||||
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(collected);
|
||||
}
|
||||
});
|
||||
|
||||
void handle.session
|
||||
.startTurn("write a very long essay about the history of computing")
|
||||
.then((result) => {
|
||||
if (turnId && turnId !== result.turnId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(
|
||||
new Error(
|
||||
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
turnId = result.turnId;
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
expect(turnId).toBeDefined();
|
||||
|
||||
// turn_canceled or turn_failed arrives for that turnId
|
||||
const terminal = events.find(
|
||||
(e) =>
|
||||
|
||||
@@ -441,7 +441,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("routes input_json_delta through partial parsing before buffered JSON is complete", async () => {
|
||||
test("waits for complete JSON values before updating tool input from input_json_delta", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
|
||||
@@ -479,7 +479,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
partial_json: '{"command":"echo ',
|
||||
},
|
||||
},
|
||||
expectedCommand: "echo ",
|
||||
expectedCommand: "echo seed",
|
||||
},
|
||||
{
|
||||
event: {
|
||||
@@ -520,7 +520,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("surfaces canonical partial tool input from input_json_delta before JSON is complete", async () => {
|
||||
test("does not surface incomplete string values from input_json_delta", async () => {
|
||||
const session = await createSession();
|
||||
const internal = session as unknown as {
|
||||
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
|
||||
@@ -551,7 +551,6 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
|
||||
expect(internal.toolUseCache.get(toolUseId)?.input).toEqual({
|
||||
file_path: "src/message.tsx",
|
||||
old_string: "before",
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
|
||||
@@ -13,33 +13,25 @@ describe("parsePartialJsonObject", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps partial string values without field-specific logic", () => {
|
||||
it("does not emit incomplete string values", () => {
|
||||
expect(parsePartialJsonObject('{"command":"echo ')).toEqual({
|
||||
value: {
|
||||
command: "echo ",
|
||||
},
|
||||
value: {},
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns parsed prefix fields from incomplete objects", () => {
|
||||
it("returns only complete prefix fields from incomplete objects", () => {
|
||||
expect(parsePartialJsonObject('{"file_path":"src/message.tsx","old_string":"before')).toEqual({
|
||||
value: {
|
||||
file_path: "src/message.tsx",
|
||||
old_string: "before",
|
||||
},
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses nested partial values generically", () => {
|
||||
it("does not emit incomplete nested values", () => {
|
||||
expect(parsePartialJsonObject('{"payload":{"path":"src/index.ts","content":"hello')).toEqual({
|
||||
value: {
|
||||
payload: {
|
||||
path: "src/index.ts",
|
||||
content: "hello",
|
||||
},
|
||||
},
|
||||
value: {},
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,17 +271,17 @@ function parsePartialObject(
|
||||
};
|
||||
}
|
||||
|
||||
value[parsedKey.value] = parsedMemberValue.value;
|
||||
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
|
||||
|
||||
if (!parsedMemberValue.complete) {
|
||||
return {
|
||||
value,
|
||||
nextIndex: currentIndex,
|
||||
nextIndex: skipWhitespace(input, parsedMemberValue.nextIndex),
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
value[parsedKey.value] = parsedMemberValue.value;
|
||||
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
|
||||
|
||||
const delimiter = input[currentIndex];
|
||||
if (delimiter === ",") {
|
||||
currentIndex += 1;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load system prompt from agent-prompt.md in package root
|
||||
export function loadSystemPrompt(): string {
|
||||
const promptPath = path.join(__dirname, "../../../agent-prompt.md");
|
||||
return readFileSync(promptPath, "utf-8");
|
||||
}
|
||||
|
||||
// Cache the loaded prompt
|
||||
let cachedPrompt: string | null = null;
|
||||
|
||||
export function getSystemPrompt(): string {
|
||||
if (!cachedPrompt) {
|
||||
cachedPrompt = loadSystemPrompt();
|
||||
}
|
||||
return cachedPrompt;
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
```typescript
|
||||
const server = new McpServer({
|
||||
name: "my-app",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Simple tool with parameters
|
||||
server.registerTool(
|
||||
"calculate-bmi",
|
||||
{
|
||||
title: "BMI Calculator",
|
||||
description: "Calculate Body Mass Index",
|
||||
inputSchema: {
|
||||
weightKg: z.number(),
|
||||
heightM: z.number(),
|
||||
},
|
||||
outputSchema: { bmi: z.number() },
|
||||
},
|
||||
async ({ weightKg, heightM }) => {
|
||||
const output = { bmi: weightKg / (heightM * heightM) };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(output),
|
||||
},
|
||||
],
|
||||
structuredContent: output,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Async tool with external API call
|
||||
server.registerTool(
|
||||
"fetch-weather",
|
||||
{
|
||||
title: "Weather Fetcher",
|
||||
description: "Get weather data for a city",
|
||||
inputSchema: { city: z.string() },
|
||||
outputSchema: { temperature: z.number(), conditions: z.string() },
|
||||
},
|
||||
async ({ city }) => {
|
||||
const response = await fetch(`https://api.weather.com/${city}`);
|
||||
const data = await response.json();
|
||||
const output = { temperature: data.temp, conditions: data.conditions };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||
structuredContent: output,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool that returns ResourceLinks
|
||||
server.registerTool(
|
||||
"list-files",
|
||||
{
|
||||
title: "List Files",
|
||||
description: "List project files",
|
||||
inputSchema: { pattern: z.string() },
|
||||
outputSchema: {
|
||||
count: z.number(),
|
||||
files: z.array(z.object({ name: z.string(), uri: z.string() })),
|
||||
},
|
||||
},
|
||||
async ({ pattern }) => {
|
||||
const output = {
|
||||
count: 2,
|
||||
files: [
|
||||
{ name: "README.md", uri: "file:///project/README.md" },
|
||||
{ name: "index.ts", uri: "file:///project/src/index.ts" },
|
||||
],
|
||||
};
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: JSON.stringify(output) },
|
||||
// ResourceLinks let tools return references without file content
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "file:///project/README.md",
|
||||
name: "README.md",
|
||||
mimeType: "text/markdown",
|
||||
description: "A README file",
|
||||
},
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "file:///project/src/index.ts",
|
||||
name: "index.ts",
|
||||
mimeType: "text/typescript",
|
||||
description: "An index file",
|
||||
},
|
||||
],
|
||||
structuredContent: output,
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
|
||||
bench('InMemoryTransport', async () => {
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test client',
|
||||
version: '1.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const server = createServer();
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const tools = await client.listTools();
|
||||
if (!tools.tools.length) throw new Error('No tools found');
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
```
|
||||
@@ -1,7 +0,0 @@
|
||||
export { createTerminalMcpServer, type TerminalMcpServerOptions } from "./server.js";
|
||||
export { TerminalManager } from "./terminal-manager.js";
|
||||
export type {
|
||||
TerminalInfo,
|
||||
CreateTerminalParams,
|
||||
CreateTerminalResult,
|
||||
} from "./terminal-manager.js";
|
||||
@@ -1,266 +0,0 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { ensureValidJson } from "../json-utils.js";
|
||||
import { TerminalManager } from "./terminal-manager.js";
|
||||
|
||||
export interface TerminalMcpServerOptions {
|
||||
sessionName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure the Terminal MCP Server
|
||||
* Multiple instances can run independently with different session names
|
||||
*/
|
||||
export async function createTerminalMcpServer(
|
||||
options: TerminalMcpServerOptions,
|
||||
): Promise<McpServer> {
|
||||
const { sessionName } = options;
|
||||
const terminalManager = new TerminalManager(sessionName);
|
||||
|
||||
// Initialize the session
|
||||
await terminalManager.initialize();
|
||||
|
||||
const server = new McpServer({
|
||||
name: "terminal-mcp",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// COMMAND-BASED TOOLS
|
||||
|
||||
// Tool: execute_command (disabled – commands must be run through coding agents)
|
||||
// server.registerTool(
|
||||
// "execute_command",
|
||||
// {
|
||||
// title: "Execute Command",
|
||||
// description:
|
||||
// "Execute a shell command in a specified directory. Commands are wrapped in bash -c, so you can use pipes, operators, redirects, and all bash features. The command runs until completion or until output stabilizes (for interactive processes). Returns command ID for follow-up interactions, output, exit code (if finished), and whether the process is still running. Windows remain after command exits for inspection.",
|
||||
// inputSchema: {
|
||||
// command: z
|
||||
// .string()
|
||||
// .describe(
|
||||
// "The command to execute. Can include pipes (|), operators (&&, ||, ;), redirects (>, >>), and any bash syntax. Examples: 'npm test', 'ls | grep foo', 'cd src && npm run build'"
|
||||
// ),
|
||||
// directory: z
|
||||
// .string()
|
||||
// .describe(
|
||||
// "Absolute path to the working directory. Can use ~ for home directory."
|
||||
// ),
|
||||
// maxWait: z
|
||||
// .number()
|
||||
// .optional()
|
||||
// .describe(
|
||||
// "Maximum milliseconds to wait for command completion or output stability (default: 120000 = 2 minutes). For interactive commands, returns when output stabilizes. For one-shot commands, returns when command exits."
|
||||
// ),
|
||||
// },
|
||||
// outputSchema: {
|
||||
// commandId: z.string(),
|
||||
// output: z.string(),
|
||||
// exitCode: z.number().nullable(),
|
||||
// isDead: z.boolean(),
|
||||
// },
|
||||
// },
|
||||
// async ({ command, directory, maxWait }) => {
|
||||
// const result = await terminalManager.executeCommand(
|
||||
// command,
|
||||
// directory,
|
||||
// maxWait
|
||||
// );
|
||||
// return {
|
||||
// content: [],
|
||||
// structuredContent: result,
|
||||
// };
|
||||
// }
|
||||
// );
|
||||
|
||||
// Tool: list_commands
|
||||
server.registerTool(
|
||||
"list_commands",
|
||||
{
|
||||
title: "List Commands",
|
||||
description:
|
||||
"List all commands (both running and exited). Shows command ID, working directory, current process, whether it has exited (isDead), exit code (if exited), and last few lines of output. Includes dead commands that remain for inspection until explicitly killed.",
|
||||
inputSchema: {},
|
||||
outputSchema: {
|
||||
commands: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
currentCommand: z.string(),
|
||||
isDead: z.boolean(),
|
||||
exitCode: z.number().nullable(),
|
||||
lastLines: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const commands = await terminalManager.listCommands();
|
||||
const output = { commands };
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson(output),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: capture_command
|
||||
server.registerTool(
|
||||
"capture_command",
|
||||
{
|
||||
title: "Capture Command Output",
|
||||
description:
|
||||
"Capture output from a command by its ID. Works for both running and exited commands. Returns output, exit code (if exited), and whether the command has finished.",
|
||||
inputSchema: {
|
||||
commandId: z
|
||||
.string()
|
||||
.describe(
|
||||
"Command ID (window ID like @123) returned from execute_command or list_commands",
|
||||
),
|
||||
lines: z.number().optional().describe("Number of lines to capture (default: 200)"),
|
||||
},
|
||||
outputSchema: {
|
||||
output: z.string(),
|
||||
exitCode: z.number().nullable(),
|
||||
isDead: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ commandId, lines }) => {
|
||||
const result = await terminalManager.captureCommand(commandId, lines);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson(result),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: send_text_to_command
|
||||
server.registerTool(
|
||||
"send_text_to_command",
|
||||
{
|
||||
title: "Send Text to Command",
|
||||
description:
|
||||
"Send text input to a running command (by ID). Use this for interactive processes like REPLs, prompts, or text-based interfaces. Only works if the command is still running (isDead=false).",
|
||||
inputSchema: {
|
||||
commandId: z
|
||||
.string()
|
||||
.describe("Command ID (window ID like @123) from execute_command or list_commands"),
|
||||
text: z.string().describe("Text to send to the command"),
|
||||
pressEnter: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Press Enter after typing text (default: false)"),
|
||||
return_output: z
|
||||
.object({
|
||||
lines: z.number().optional().describe("Number of lines to capture (default: 200)"),
|
||||
waitForSettled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Wait for output to stabilize before returning (default: true)"),
|
||||
maxWait: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Maximum milliseconds to wait for stability (default: 120000)"),
|
||||
})
|
||||
.optional()
|
||||
.describe("Capture output after sending text"),
|
||||
},
|
||||
outputSchema: {
|
||||
output: z.string().nullable(),
|
||||
},
|
||||
},
|
||||
async ({ commandId, text, pressEnter, return_output }) => {
|
||||
const output = await terminalManager.sendTextToCommand(
|
||||
commandId,
|
||||
text,
|
||||
pressEnter,
|
||||
return_output,
|
||||
);
|
||||
const result = { output: output || null };
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson(result),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: send_keys_to_command
|
||||
server.registerTool(
|
||||
"send_keys_to_command",
|
||||
{
|
||||
title: "Send Keys to Command",
|
||||
description:
|
||||
"Send special keys or key combinations to a running command. Use for control sequences and navigation. Examples: 'C-c' (Ctrl+C), 'Enter', 'Escape', 'BTab' (Shift+Tab). Only works if command is still running.",
|
||||
inputSchema: {
|
||||
commandId: z.string().describe("Command ID (window ID like @123)"),
|
||||
keys: z
|
||||
.string()
|
||||
.describe("Special key or combination: 'C-c', 'Enter', 'Escape', 'BTab', etc."),
|
||||
repeat: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Number of times to repeat the key press (default: 1)"),
|
||||
return_output: z
|
||||
.object({
|
||||
lines: z.number().optional(),
|
||||
waitForSettled: z.boolean().optional(),
|
||||
maxWait: z.number().optional(),
|
||||
})
|
||||
.optional()
|
||||
.describe("Capture output after sending keys"),
|
||||
},
|
||||
outputSchema: {
|
||||
output: z.string().nullable(),
|
||||
},
|
||||
},
|
||||
async ({ commandId, keys, repeat, return_output }) => {
|
||||
const output = await terminalManager.sendKeysToCommand(
|
||||
commandId,
|
||||
keys,
|
||||
repeat,
|
||||
return_output,
|
||||
);
|
||||
const result = { output: output || null };
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson(result),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: kill_command
|
||||
server.registerTool(
|
||||
"kill_command",
|
||||
{
|
||||
title: "Kill Command",
|
||||
description:
|
||||
"Kill one or more commands and close their windows. Use this to terminate running processes or clean up finished commands. This removes the commands from list_commands.",
|
||||
inputSchema: {
|
||||
commandIds: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe(
|
||||
"Array of command IDs (window IDs like @123) from execute_command or list_commands. Can be a single ID or multiple IDs.",
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
killedCount: z.number(),
|
||||
},
|
||||
},
|
||||
async ({ commandIds }) => {
|
||||
let killedCount = 0;
|
||||
for (const commandId of commandIds) {
|
||||
await terminalManager.killCommand(commandId);
|
||||
killedCount++;
|
||||
}
|
||||
const output = { success: true, killedCount };
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson(output),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { TerminalManager } from "./terminal-manager.js";
|
||||
import { findSessionByName, killSession } from "./tmux.js";
|
||||
|
||||
const TEST_SESSION = `test-terminal-manager-${process.pid}-${Date.now().toString(36)}`;
|
||||
|
||||
const ANSI_ESCAPE_REGEX = /\u001B[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_REGEX, "");
|
||||
}
|
||||
|
||||
function expectMissingPathMessage(output: string, missingPath: string): void {
|
||||
const normalized = stripAnsi(output).toLowerCase();
|
||||
expect(normalized).toContain(missingPath.toLowerCase());
|
||||
expect(normalized).toMatch(/cannot access|no such file|not found/);
|
||||
}
|
||||
|
||||
describe("TerminalManager - Command Execution", () => {
|
||||
let manager: TerminalManager;
|
||||
|
||||
beforeAll(async () => {
|
||||
manager = new TerminalManager(TEST_SESSION);
|
||||
await manager.initialize();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup test session
|
||||
const session = await findSessionByName(TEST_SESSION);
|
||||
if (session) {
|
||||
await killSession(session.id);
|
||||
}
|
||||
});
|
||||
|
||||
describe("executeCommand", () => {
|
||||
it("should execute a simple one-shot command and return exit code", async () => {
|
||||
const result = await manager.executeCommand(
|
||||
"echo 'Hello World'",
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(result.commandId).toMatch(/^@\d+$/);
|
||||
expect(result.output).toContain("Hello World");
|
||||
expect(result.isDead).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle commands with pipes and operators", async () => {
|
||||
const result = await manager.executeCommand(
|
||||
"echo 'line1\nline2\nline3' | grep line2",
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(result.output).toContain("line2");
|
||||
expect(result.isDead).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should capture non-zero exit codes", async () => {
|
||||
const result = await manager.executeCommand(
|
||||
"ls /nonexistent-directory-test 2>&1",
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(result.isDead).toBe(true);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expectMissingPathMessage(result.output, "nonexistent-directory-test");
|
||||
});
|
||||
|
||||
it("should handle directory changes in command", async () => {
|
||||
const result = await manager.executeCommand("cd /tmp && pwd", process.env.HOME || "~", 5000);
|
||||
|
||||
expect(result.output).toContain("/tmp");
|
||||
expect(result.isDead).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should launch interactive command (Python REPL)", async () => {
|
||||
const result = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
|
||||
|
||||
expect(result.commandId).toMatch(/^@\d+$/);
|
||||
expect(result.output).toContain(">>>"); // Python prompt
|
||||
expect(result.isDead).toBe(false); // Still running
|
||||
expect(result.exitCode).toBeNull(); // No exit code yet
|
||||
|
||||
// Cleanup
|
||||
await manager.killCommand(result.commandId);
|
||||
});
|
||||
|
||||
it("should handle command with working directory", async () => {
|
||||
const result = await manager.executeCommand("pwd", "/tmp", 5000);
|
||||
|
||||
expect(result.output).toContain("/tmp");
|
||||
expect(result.isDead).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle long-running command with timeout", async () => {
|
||||
const result = await manager.executeCommand(
|
||||
"sleep 0.5 && echo 'Done sleeping'",
|
||||
process.env.HOME || "~",
|
||||
3000, // Increased timeout to ensure command completes
|
||||
);
|
||||
|
||||
expect(result.output).toContain("Done sleeping");
|
||||
// Note: May still be alive if not enough time to detect completion
|
||||
if (result.isDead) {
|
||||
expect(result.exitCode).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("listCommands", () => {
|
||||
it("should list all commands including dead ones", async () => {
|
||||
// Execute a command that finishes
|
||||
const result1 = await manager.executeCommand("echo 'test1'", process.env.HOME || "~", 5000);
|
||||
|
||||
// Execute a command that stays running
|
||||
const result2 = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
|
||||
|
||||
const commands = await manager.listCommands();
|
||||
|
||||
expect(commands.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const cmd1 = commands.find((c) => c.id === result1.commandId);
|
||||
expect(cmd1).toBeDefined();
|
||||
expect(cmd1?.isDead).toBe(true);
|
||||
expect(cmd1?.exitCode).toBe(0);
|
||||
|
||||
const cmd2 = commands.find((c) => c.id === result2.commandId);
|
||||
expect(cmd2).toBeDefined();
|
||||
expect(cmd2?.isDead).toBe(false);
|
||||
expect(cmd2?.exitCode).toBeNull();
|
||||
|
||||
// Cleanup
|
||||
await manager.killCommand(result2.commandId);
|
||||
});
|
||||
|
||||
it("should preserve command and working directory info for dead commands", async () => {
|
||||
const testDir1 = "/tmp";
|
||||
const testCommand1 = "echo 'test message 1' && pwd";
|
||||
const testDir2 = process.env.HOME || "~";
|
||||
const testCommand2 = "echo 'test message 2'";
|
||||
|
||||
// Execute two different commands
|
||||
const result1 = await manager.executeCommand(testCommand1, testDir1, 5000);
|
||||
|
||||
const result2 = await manager.executeCommand(testCommand2, testDir2, 5000);
|
||||
|
||||
expect(result1.isDead).toBe(true);
|
||||
expect(result1.exitCode).toBe(0);
|
||||
expect(result2.isDead).toBe(true);
|
||||
expect(result2.exitCode).toBe(0);
|
||||
|
||||
// List commands - should show the original command and working directory for each
|
||||
const commands = await manager.listCommands();
|
||||
const cmd1 = commands.find((c) => c.id === result1.commandId);
|
||||
const cmd2 = commands.find((c) => c.id === result2.commandId);
|
||||
|
||||
// Verify first command
|
||||
expect(cmd1).toBeDefined();
|
||||
expect(cmd1?.isDead).toBe(true);
|
||||
expect(cmd1?.exitCode).toBe(0);
|
||||
expect(cmd1?.workingDirectory).toBe(testDir1);
|
||||
expect(cmd1?.currentCommand).toBe(testCommand1);
|
||||
|
||||
// Verify second command (should have different values)
|
||||
expect(cmd2).toBeDefined();
|
||||
expect(cmd2?.isDead).toBe(true);
|
||||
expect(cmd2?.exitCode).toBe(0);
|
||||
expect(cmd2?.workingDirectory).toBe(testDir2);
|
||||
expect(cmd2?.currentCommand).toBe(testCommand2);
|
||||
|
||||
// Ensure they're actually different
|
||||
expect(cmd1?.workingDirectory).not.toBe(cmd2?.workingDirectory);
|
||||
expect(cmd1?.currentCommand).not.toBe(cmd2?.currentCommand);
|
||||
|
||||
// Cleanup
|
||||
await manager.killCommand(result1.commandId);
|
||||
await manager.killCommand(result2.commandId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureCommand", () => {
|
||||
it("should capture output from finished command", async () => {
|
||||
const execResult = await manager.executeCommand(
|
||||
"echo 'Capture test'",
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
const captureResult = await manager.captureCommand(execResult.commandId, 100);
|
||||
|
||||
expect(captureResult.output).toContain("Capture test");
|
||||
expect(captureResult.isDead).toBe(true);
|
||||
expect(captureResult.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should capture output from running command", async () => {
|
||||
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
|
||||
|
||||
const captureResult = await manager.captureCommand(execResult.commandId, 50);
|
||||
|
||||
expect(captureResult.output).toContain(">>>");
|
||||
expect(captureResult.isDead).toBe(false);
|
||||
expect(captureResult.exitCode).toBeNull();
|
||||
|
||||
// Cleanup
|
||||
await manager.killCommand(execResult.commandId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendTextToCommand", () => {
|
||||
it("should send text to running Python REPL", async () => {
|
||||
// Launch Python
|
||||
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
|
||||
|
||||
// Send Python code
|
||||
const output = await manager.sendTextToCommand(
|
||||
execResult.commandId,
|
||||
"print('Hello from Python')",
|
||||
true, // press Enter
|
||||
{ lines: 50, maxWait: 3000, waitForSettled: true },
|
||||
);
|
||||
|
||||
expect(output).toContain("Hello from Python");
|
||||
|
||||
// Send exit
|
||||
await manager.sendTextToCommand(execResult.commandId, "exit()", true, {
|
||||
lines: 50,
|
||||
maxWait: 2000,
|
||||
});
|
||||
|
||||
// Verify command is now dead
|
||||
const captureResult = await manager.captureCommand(execResult.commandId);
|
||||
expect(captureResult.isDead).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple sequential inputs to REPL", async () => {
|
||||
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
|
||||
|
||||
await manager.sendTextToCommand(execResult.commandId, "x = 5", true);
|
||||
await manager.sendTextToCommand(execResult.commandId, "y = 3", true);
|
||||
|
||||
const output = await manager.sendTextToCommand(execResult.commandId, "print(x + y)", true, {
|
||||
lines: 50,
|
||||
maxWait: 2000,
|
||||
});
|
||||
|
||||
expect(output).toContain("8");
|
||||
|
||||
// Cleanup
|
||||
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendKeysToCommand", () => {
|
||||
it("should send Ctrl-C to interrupt running command", async () => {
|
||||
// Start a long-running command
|
||||
const execResult = await manager.executeCommand(
|
||||
"sleep 100",
|
||||
process.env.HOME || "~",
|
||||
1000, // Short wait, won't finish
|
||||
);
|
||||
|
||||
expect(execResult.isDead).toBe(false);
|
||||
|
||||
// Send Ctrl-C
|
||||
await manager.sendKeysToCommand(execResult.commandId, "C-c", 1, { lines: 50, maxWait: 2000 });
|
||||
|
||||
// Wait a bit for signal to process
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Check if command is now dead
|
||||
const captureResult = await manager.captureCommand(execResult.commandId);
|
||||
expect(captureResult.isDead).toBe(true);
|
||||
});
|
||||
|
||||
it("should send Enter key to REPL", async () => {
|
||||
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
|
||||
|
||||
// Type without pressing Enter
|
||||
await manager.sendTextToCommand(
|
||||
execResult.commandId,
|
||||
"print('test')",
|
||||
false, // Don't press Enter
|
||||
);
|
||||
|
||||
// Now send Enter via sendKeys
|
||||
const output = await manager.sendKeysToCommand(execResult.commandId, "Enter", 1, {
|
||||
lines: 50,
|
||||
maxWait: 2000,
|
||||
});
|
||||
|
||||
expect(output).toContain("test");
|
||||
|
||||
// Cleanup
|
||||
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
|
||||
});
|
||||
|
||||
it("should repeat key press multiple times", async () => {
|
||||
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
|
||||
|
||||
// Send multiple Ctrl-C (should exit Python)
|
||||
await manager.sendKeysToCommand(
|
||||
execResult.commandId,
|
||||
"C-c",
|
||||
3, // Repeat 3 times
|
||||
{ lines: 50, maxWait: 3000 },
|
||||
);
|
||||
|
||||
// Wait longer for Python to exit
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const captureResult = await manager.captureCommand(execResult.commandId);
|
||||
// Python might not exit from Ctrl-C depending on state, so just verify we can still capture
|
||||
expect(captureResult.output).toBeDefined();
|
||||
|
||||
// Cleanup if still running
|
||||
if (!captureResult.isDead) {
|
||||
await manager.killCommand(execResult.commandId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("killCommand", () => {
|
||||
it("should kill running command", async () => {
|
||||
const execResult = await manager.executeCommand("sleep 100", process.env.HOME || "~", 1000);
|
||||
|
||||
expect(execResult.isDead).toBe(false);
|
||||
|
||||
// Kill the command
|
||||
await manager.killCommand(execResult.commandId);
|
||||
|
||||
// Verify it's gone from list
|
||||
const commands = await manager.listCommands();
|
||||
const found = commands.find((c) => c.id === execResult.commandId);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should kill finished command (cleanup)", async () => {
|
||||
const execResult = await manager.executeCommand(
|
||||
"echo 'cleanup test'",
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(execResult.isDead).toBe(true);
|
||||
|
||||
// Kill/cleanup the command
|
||||
await manager.killCommand(execResult.commandId);
|
||||
|
||||
// Verify it's gone from list
|
||||
const commands = await manager.listCommands();
|
||||
const found = commands.find((c) => c.id === execResult.commandId);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complex workflows", () => {
|
||||
it("should handle Node.js REPL workflow", async () => {
|
||||
// Launch Node REPL
|
||||
const execResult = await manager.executeCommand("node", process.env.HOME || "~", 5000);
|
||||
|
||||
expect(execResult.output).toContain(">"); // Node prompt
|
||||
expect(execResult.isDead).toBe(false);
|
||||
|
||||
await manager.sendTextToCommand(execResult.commandId, "const x = [1, 2, 3]", true);
|
||||
|
||||
const output2 = await manager.sendTextToCommand(
|
||||
execResult.commandId,
|
||||
"console.log(x.map(n => n * 2).join(','))",
|
||||
true,
|
||||
{ lines: 50, maxWait: 2000 },
|
||||
);
|
||||
|
||||
expect(output2).toContain("2,4,6");
|
||||
|
||||
// Exit
|
||||
await manager.sendTextToCommand(execResult.commandId, ".exit", true);
|
||||
|
||||
// Verify exited
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const captureResult = await manager.captureCommand(execResult.commandId);
|
||||
expect(captureResult.isDead).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle command that produces streaming output", async () => {
|
||||
const execResult = await manager.executeCommand(
|
||||
"for i in 1 2 3; do echo Line $i; sleep 0.1; done",
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(execResult.output).toContain("Line 1");
|
||||
expect(execResult.output).toContain("Line 2");
|
||||
expect(execResult.output).toContain("Line 3");
|
||||
expect(execResult.isDead).toBe(true);
|
||||
expect(execResult.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle command with stderr output", async () => {
|
||||
const execResult = await manager.executeCommand(
|
||||
"echo 'to stdout'; echo 'to stderr' >&2",
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(execResult.output).toContain("to stdout");
|
||||
expect(execResult.output).toContain("to stderr");
|
||||
expect(execResult.isDead).toBe(true);
|
||||
expect(execResult.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty command output", async () => {
|
||||
const execResult = await manager.executeCommand(
|
||||
"true", // Command that produces no output
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(execResult.isDead).toBe(true);
|
||||
expect(execResult.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle command with tilde in directory", async () => {
|
||||
const execResult = await manager.executeCommand("pwd", "~/", 5000);
|
||||
|
||||
expect(execResult.output).toContain(process.env.HOME || "");
|
||||
expect(execResult.isDead).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very long output", async () => {
|
||||
const execResult = await manager.executeCommand(
|
||||
"seq 1 500", // Generate 500 lines
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(execResult.output).toContain("1");
|
||||
expect(execResult.output).toContain("500");
|
||||
expect(execResult.isDead).toBe(true);
|
||||
expect(execResult.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle special characters in command", async () => {
|
||||
const execResult = await manager.executeCommand(
|
||||
"echo 'Special: $test & | ; < >'",
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(execResult.output).toContain("Special:");
|
||||
expect(execResult.isDead).toBe(true);
|
||||
expect(execResult.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle command with quotes", async () => {
|
||||
const execResult = await manager.executeCommand(
|
||||
`echo 'double and single quotes'`,
|
||||
process.env.HOME || "~",
|
||||
5000,
|
||||
);
|
||||
|
||||
expect(execResult.output).toContain("double");
|
||||
expect(execResult.output).toContain("single");
|
||||
expect(execResult.isDead).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,502 +0,0 @@
|
||||
import {
|
||||
findSessionByName,
|
||||
createSession,
|
||||
listWindows,
|
||||
createWindow,
|
||||
listPanes,
|
||||
findWindowByName,
|
||||
capturePaneContent,
|
||||
extractExitCodeMarkerFromOutput,
|
||||
sendText as tmuxSendText,
|
||||
sendKeys as tmuxSendKeys,
|
||||
renameWindow,
|
||||
killWindow,
|
||||
isWindowNameUnique,
|
||||
getCurrentWorkingDirectory,
|
||||
getCurrentCommand,
|
||||
getStoredWorkingDirectory,
|
||||
getStoredCommand,
|
||||
waitForPaneActivityToSettle,
|
||||
executeCommand as tmuxExecuteCommand,
|
||||
executeTmux,
|
||||
} from "./tmux.js";
|
||||
|
||||
// Terminal model: session → windows (single pane per window)
|
||||
// Terminals are identified by their unique names, not IDs
|
||||
|
||||
export interface TerminalInfo {
|
||||
name: string;
|
||||
workingDirectory: string;
|
||||
currentCommand: string;
|
||||
lastLines: string | null;
|
||||
}
|
||||
|
||||
export interface CommandInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
workingDirectory: string;
|
||||
currentCommand: string;
|
||||
isDead: boolean;
|
||||
exitCode: number | null;
|
||||
lastLines: string | null;
|
||||
}
|
||||
|
||||
export interface CreateTerminalParams {
|
||||
name: string;
|
||||
workingDirectory: string;
|
||||
initialCommand?: string;
|
||||
}
|
||||
|
||||
export interface CreateTerminalResult extends TerminalInfo {
|
||||
commandOutput: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal manager for a specific tmux session
|
||||
* Multiple instances can coexist with different session names
|
||||
*/
|
||||
export class TerminalManager {
|
||||
constructor(private sessionName: string) {}
|
||||
|
||||
/**
|
||||
* Initialize the tmux session
|
||||
* Creates it if it doesn't exist
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
await createSession(this.sessionName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all terminals in the session
|
||||
* Returns terminal info including name, active status, working directory, and current command
|
||||
*/
|
||||
async listTerminals(): Promise<TerminalInfo[]> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
|
||||
}
|
||||
|
||||
const windows = await listWindows(session.id);
|
||||
|
||||
const terminals: TerminalInfo[] = [];
|
||||
|
||||
for (const window of windows) {
|
||||
// Get the first (and only) pane in this window
|
||||
const paneId = `${window.id}.0`;
|
||||
|
||||
const workingDirectory = await getCurrentWorkingDirectory(paneId);
|
||||
const currentCommand = await getCurrentCommand(paneId);
|
||||
const lastLines = await capturePaneContent(paneId, 5, false);
|
||||
|
||||
terminals.push({
|
||||
name: window.name,
|
||||
workingDirectory,
|
||||
currentCommand,
|
||||
lastLines: lastLines || null,
|
||||
});
|
||||
}
|
||||
|
||||
return terminals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new terminal (tmux window) with specified name and working directory
|
||||
* Optionally execute an initial command
|
||||
*/
|
||||
async createTerminal(params: CreateTerminalParams): Promise<CreateTerminalResult> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
|
||||
}
|
||||
|
||||
// Validate name uniqueness
|
||||
const isUnique = await isWindowNameUnique(session.id, params.name);
|
||||
if (!isUnique) {
|
||||
throw new Error(
|
||||
`Terminal with name '${params.name}' already exists. Please choose a unique name.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create the window
|
||||
const windowResult = await createWindow(session.id, params.name, {
|
||||
workingDirectory: params.workingDirectory,
|
||||
command: params.initialCommand ?? null,
|
||||
});
|
||||
|
||||
if (!windowResult) {
|
||||
throw new Error(`Failed to create terminal '${params.name}'`);
|
||||
}
|
||||
|
||||
const paneId = windowResult.paneId;
|
||||
|
||||
// Get terminal info
|
||||
const workingDirectory = await getCurrentWorkingDirectory(paneId);
|
||||
const currentCommand = await getCurrentCommand(paneId);
|
||||
|
||||
return {
|
||||
name: windowResult.name,
|
||||
workingDirectory,
|
||||
currentCommand,
|
||||
commandOutput: windowResult.output || null,
|
||||
lastLines: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture output from a terminal by name
|
||||
* Returns the last N lines of terminal content
|
||||
* If maxWait is provided, waits for terminal activity to settle before capturing
|
||||
*/
|
||||
async captureTerminal(
|
||||
terminalName: string,
|
||||
lines: number = 200,
|
||||
maxWait?: number,
|
||||
): Promise<string> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first pane
|
||||
const panes = await listPanes(window.id);
|
||||
const pane = panes[0];
|
||||
if (!pane) {
|
||||
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||
}
|
||||
|
||||
// Wait for activity to settle if maxWait is provided
|
||||
if (maxWait) {
|
||||
return waitForPaneActivityToSettle(pane.id, maxWait, lines);
|
||||
}
|
||||
|
||||
return capturePaneContent(pane.id, lines, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send text to a terminal by name, optionally press Enter, optionally return output
|
||||
*/
|
||||
async sendText(
|
||||
terminalName: string,
|
||||
text: string,
|
||||
pressEnter: boolean = false,
|
||||
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
|
||||
): Promise<string | null> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first pane
|
||||
const panes = await listPanes(window.id);
|
||||
const pane = panes[0];
|
||||
if (!pane) {
|
||||
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||
}
|
||||
|
||||
return tmuxSendText({
|
||||
paneId: pane.id,
|
||||
text,
|
||||
pressEnter,
|
||||
return_output,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send special keys or key combinations to a terminal by name
|
||||
* Useful for TUI navigation, control sequences, and interactive applications
|
||||
*/
|
||||
async sendKeys(
|
||||
terminalName: string,
|
||||
keys: string,
|
||||
repeat: number = 1,
|
||||
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
|
||||
): Promise<string | null> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first pane
|
||||
const panes = await listPanes(window.id);
|
||||
const pane = panes[0];
|
||||
if (!pane) {
|
||||
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||
}
|
||||
|
||||
return tmuxSendKeys({
|
||||
paneId: pane.id,
|
||||
keys,
|
||||
repeat,
|
||||
return_output,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a terminal by name
|
||||
* Validates that the new name is unique
|
||||
*/
|
||||
async renameTerminal(terminalName: string, newName: string): Promise<void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// renameWindow handles uniqueness validation and name resolution internally
|
||||
await renameWindow(session.id, terminalName, newName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill (close/destroy) a terminal by name
|
||||
*/
|
||||
async killTerminal(terminalName: string): Promise<void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
await killWindow(window.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return its output, exit code, and running status
|
||||
* Commands are wrapped in bash -c to support all bash features
|
||||
* Windows remain after command exits (remain-on-exit) for inspection
|
||||
*/
|
||||
async executeCommand(
|
||||
command: string,
|
||||
workingDirectory: string,
|
||||
maxWait?: number,
|
||||
): Promise<{
|
||||
commandId: string;
|
||||
output: string;
|
||||
exitCode: number | null;
|
||||
isDead: boolean;
|
||||
}> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
|
||||
}
|
||||
|
||||
const result = await tmuxExecuteCommand({
|
||||
sessionId: session.id,
|
||||
command,
|
||||
workingDirectory,
|
||||
maxWait,
|
||||
});
|
||||
|
||||
return {
|
||||
commandId: result.windowId,
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
isDead: result.isDead,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all commands (windows), including those that have exited
|
||||
* Shows both running and dead commands for inspection
|
||||
*/
|
||||
async listCommands(): Promise<CommandInfo[]> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
|
||||
}
|
||||
|
||||
const windows = await listWindows(session.id);
|
||||
const commands: CommandInfo[] = [];
|
||||
|
||||
for (const window of windows) {
|
||||
const paneId = `${window.id}.0`;
|
||||
|
||||
// Check if pane is dead first
|
||||
const deadStatus = await executeTmux(["display-message", "-p", "-t", paneId, "#{pane_dead}"]);
|
||||
const isDead = deadStatus === "1";
|
||||
|
||||
// Use stored values for dead panes, current values for live ones
|
||||
const workingDirectory = await getStoredWorkingDirectory(window.id, paneId);
|
||||
const currentCommand = await getStoredCommand(window.id, paneId);
|
||||
const rawLastLines = await capturePaneContent(paneId, 5, false);
|
||||
const lastLinesExtracted = extractExitCodeMarkerFromOutput(rawLastLines);
|
||||
const lastLines = lastLinesExtracted.output;
|
||||
|
||||
let exitCode: number | null = null;
|
||||
if (isDead) {
|
||||
const exitCodeStr = await executeTmux([
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
paneId,
|
||||
"#{pane_dead_status}",
|
||||
]);
|
||||
const parsed = parseInt(exitCodeStr, 10);
|
||||
exitCode = Number.isFinite(parsed) ? parsed : lastLinesExtracted.exitCode;
|
||||
}
|
||||
|
||||
commands.push({
|
||||
id: window.id,
|
||||
name: window.name,
|
||||
workingDirectory,
|
||||
currentCommand,
|
||||
isDead,
|
||||
exitCode,
|
||||
lastLines: lastLines || null,
|
||||
});
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture output from a command by ID (window ID)
|
||||
*/
|
||||
async captureCommand(
|
||||
commandId: string,
|
||||
lines: number = 200,
|
||||
): Promise<{
|
||||
output: string;
|
||||
exitCode: number | null;
|
||||
isDead: boolean;
|
||||
}> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
const paneId = `${commandId}.0`;
|
||||
|
||||
const rawOutput = await capturePaneContent(paneId, lines, false);
|
||||
const extracted = extractExitCodeMarkerFromOutput(rawOutput);
|
||||
const output = extracted.output;
|
||||
|
||||
const deadStatus = await executeTmux(["display-message", "-p", "-t", paneId, "#{pane_dead}"]);
|
||||
const isDead = deadStatus === "1";
|
||||
|
||||
let exitCode: number | null = null;
|
||||
if (isDead) {
|
||||
const exitCodeStr = await executeTmux([
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
paneId,
|
||||
"#{pane_dead_status}",
|
||||
]);
|
||||
const parsed = parseInt(exitCodeStr, 10);
|
||||
exitCode = Number.isFinite(parsed) ? parsed : extracted.exitCode;
|
||||
}
|
||||
|
||||
return {
|
||||
output,
|
||||
exitCode,
|
||||
isDead,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send text to a command by ID (window ID)
|
||||
*/
|
||||
async sendTextToCommand(
|
||||
commandId: string,
|
||||
text: string,
|
||||
pressEnter: boolean = false,
|
||||
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
|
||||
): Promise<string | null> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
const paneId = `${commandId}.0`;
|
||||
|
||||
return tmuxSendText({
|
||||
paneId,
|
||||
text,
|
||||
pressEnter,
|
||||
return_output,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send keys to a command by ID (window ID)
|
||||
*/
|
||||
async sendKeysToCommand(
|
||||
commandId: string,
|
||||
keys: string,
|
||||
repeat: number = 1,
|
||||
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
|
||||
): Promise<string | null> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
const paneId = `${commandId}.0`;
|
||||
|
||||
return tmuxSendKeys({
|
||||
paneId,
|
||||
keys,
|
||||
repeat,
|
||||
return_output,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a command by ID (window ID)
|
||||
*/
|
||||
async killCommand(commandId: string): Promise<void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
await killWindow(commandId);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.33",
|
||||
"version": "0.1.34",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
// Workaround for https://github.com/npm/cli/issues/4460
|
||||
//
|
||||
// npm silently omits `resolved` and `integrity` fields from some
|
||||
// package-lock.json entries in workspace monorepos (especially for
|
||||
// workspace-hoisted packages). npm acknowledged this as a bug in 2022
|
||||
// but has never shipped a fix.
|
||||
//
|
||||
// This is harmless for regular `npm ci`, but breaks offline installers
|
||||
// like Nix that need every entry to have a resolved URL + integrity hash
|
||||
// so they can pre-fetch all tarballs in a sandbox with no network access.
|
||||
//
|
||||
// This script finds incomplete entries and fills them in using `npm view`.
|
||||
// It's idempotent — running it on an already-complete lockfile is a no-op.
|
||||
//
|
||||
// See also: https://github.com/npm/cli/issues/4263
|
||||
// https://github.com/npm/cli/issues/6301
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/fix-lockfile.mjs
|
||||
// node scripts/fix-lockfile.mjs path/to/package-lock.json
|
||||
|
||||
import fs from "fs";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const lockPath = process.argv[2] || "package-lock.json";
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
||||
|
||||
// Collect workspace package roots (local packages, not from npm)
|
||||
const workspaceRoots = new Set();
|
||||
for (const [key, val] of Object.entries(lock.packages || {})) {
|
||||
if (val.link) {
|
||||
workspaceRoots.add(val.resolved || key);
|
||||
}
|
||||
}
|
||||
|
||||
let fixed = 0;
|
||||
|
||||
for (const [key, val] of Object.entries(lock.packages || {})) {
|
||||
if (
|
||||
!key || // root package
|
||||
key.startsWith("node_modules/") || // top-level (already has resolved)
|
||||
val.link || // workspace link entry
|
||||
(val.resolved && val.integrity) || // already complete
|
||||
!val.version || // no version to look up
|
||||
workspaceRoots.has(key) // workspace package root (local, not on npm)
|
||||
)
|
||||
continue;
|
||||
|
||||
const pkgName = key.replace(/.*node_modules\//, "");
|
||||
const version = val.version;
|
||||
|
||||
try {
|
||||
const info = JSON.parse(
|
||||
execSync(`npm view ${pkgName}@${version} --json dist`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
})
|
||||
);
|
||||
if (info.tarball && info.integrity) {
|
||||
val.resolved = info.tarball;
|
||||
val.integrity = info.integrity;
|
||||
fixed++;
|
||||
}
|
||||
} catch {
|
||||
console.error(`Warning: could not fetch info for ${pkgName}@${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n");
|
||||
|
||||
if (fixed > 0) {
|
||||
console.log(`Fixed ${fixed} lockfile entries with missing resolved/integrity`);
|
||||
} else {
|
||||
console.log("Lockfile is already complete");
|
||||
}
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fix workspace-local lockfile entries and update the Nix dependency hash.
|
||||
# Requires: node, npm, nix
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/update-nix.sh # fix lockfile + update hash
|
||||
# ./scripts/update-nix.sh --check # verify everything is up to date (CI mode)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LOCK_FILE="$ROOT_DIR/package-lock.json"
|
||||
PACKAGE_NIX="$ROOT_DIR/nix/package.nix"
|
||||
|
||||
CHECK_MODE=false
|
||||
if [[ "${1:-}" == "--check" ]]; then
|
||||
CHECK_MODE=true
|
||||
fi
|
||||
|
||||
# 1. Fix lockfile (add resolved/integrity for workspace-local entries)
|
||||
# Workaround for https://github.com/npm/cli/issues/4460
|
||||
echo "Fixing lockfile..."
|
||||
node "$SCRIPT_DIR/fix-lockfile.mjs" "$LOCK_FILE"
|
||||
|
||||
# 2. Prefetch deps and compute hash
|
||||
echo "Prefetching npm dependencies..."
|
||||
|
||||
# Resolve prefetch-npm-deps from the same nixpkgs pinned in flake.lock
|
||||
NIXPKGS_URL="$(node -p "
|
||||
const l = JSON.parse(require('fs').readFileSync('$ROOT_DIR/flake.lock', 'utf8'));
|
||||
const n = l.nodes.nixpkgs.locked;
|
||||
'github:' + n.owner + '/' + n.repo + '/' + n.rev;
|
||||
")"
|
||||
|
||||
STDERR_LOG="$(mktemp)"
|
||||
trap "rm -f '$STDERR_LOG'" EXIT
|
||||
|
||||
if ! NEW_HASH="$(nix shell "${NIXPKGS_URL}#prefetch-npm-deps" -c prefetch-npm-deps "$LOCK_FILE" 2>"$STDERR_LOG")"; then
|
||||
echo "ERROR: prefetch-npm-deps failed:" >&2
|
||||
tail -20 "$STDERR_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Computed hash: $NEW_HASH"
|
||||
|
||||
# 3. Read current hash
|
||||
CURRENT_HASH="$(grep 'npmDepsHash' "$PACKAGE_NIX" | sed 's/.*"\(.*\)".*/\1/')"
|
||||
|
||||
if [[ "$NEW_HASH" == "$CURRENT_HASH" ]]; then
|
||||
echo "Hash is already up to date."
|
||||
else
|
||||
if $CHECK_MODE; then
|
||||
echo "ERROR: npmDepsHash is stale."
|
||||
echo " current: $CURRENT_HASH"
|
||||
echo " correct: $NEW_HASH"
|
||||
echo "Run ./scripts/update-nix.sh to fix."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Updating npmDepsHash in nix/package.nix..."
|
||||
sed -i.bak "s|npmDepsHash = \".*\"|npmDepsHash = \"$NEW_HASH\"|" "$PACKAGE_NIX"
|
||||
rm -f "$PACKAGE_NIX.bak"
|
||||
echo "Updated: $CURRENT_HASH -> $NEW_HASH"
|
||||
fi
|
||||
Reference in New Issue
Block a user