mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5842482b0 | ||
|
|
9b9535aa78 | ||
|
|
515cb0a777 | ||
|
|
0dc944df3a | ||
|
|
16ea92aec5 | ||
|
|
93ec537095 | ||
|
|
9255212043 | ||
|
|
a03da6774a | ||
|
|
7c5bbc8048 | ||
|
|
76e6ad89d2 | ||
|
|
93d6eb611e | ||
|
|
ce5d9d6dc8 | ||
|
|
abc146b186 |
119
.github/workflows/android-apk-release.yml
vendored
Normal file
119
.github/workflows/android-apk-release.yml
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
name: Android APK Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing tag to build (e.g. v0.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-android-apk:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Expo and EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Build Android APK on EAS
|
||||
id: eas_build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd packages/app
|
||||
|
||||
build_json="$(npx eas build --platform android --profile production-apk --non-interactive --wait --json)"
|
||||
echo "$build_json" > "$RUNNER_TEMP/eas-build.json"
|
||||
|
||||
build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")"
|
||||
if [ -z "$build_id" ]; then
|
||||
echo "Failed to determine EAS build ID."
|
||||
cat "$RUNNER_TEMP/eas-build.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "build_id=$build_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve APK artifact URL
|
||||
id: artifact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd packages/app
|
||||
|
||||
build_view_json="$(npx eas build:view '${{ steps.eas_build.outputs.build_id }}' --json)"
|
||||
echo "$build_view_json" > "$RUNNER_TEMP/eas-build-view.json"
|
||||
|
||||
artifact_url="$(jq -r '.artifacts.buildUrl // .artifacts.applicationArchiveUrl // empty' "$RUNNER_TEMP/eas-build-view.json")"
|
||||
if [ -z "$artifact_url" ]; then
|
||||
echo "Failed to determine APK artifact URL."
|
||||
cat "$RUNNER_TEMP/eas-build-view.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
asset_name="paseo-${RELEASE_TAG}-android.apk"
|
||||
asset_path="$RUNNER_TEMP/$asset_name"
|
||||
|
||||
curl --fail --location --output "$asset_path" "$artifact_url"
|
||||
|
||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Wait for GitHub release tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 90); do
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Found release for tag $RELEASE_TAG"
|
||||
exit 0
|
||||
fi
|
||||
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
|
||||
sleep 20
|
||||
done
|
||||
|
||||
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
|
||||
exit 1
|
||||
|
||||
- name: Upload APK to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.9] - 2026-02-17
|
||||
### Improved
|
||||
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
|
||||
- Reused provider availability checks for structured generation fallback selection.
|
||||
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
|
||||
|
||||
### Fixed
|
||||
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
|
||||
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
|
||||
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
|
||||
|
||||
## [0.1.8] - 2026-02-17
|
||||
### Added
|
||||
- Added a cross-platform confirm dialog flow for daemon restarts.
|
||||
|
||||
### Improved
|
||||
- Simplified local speech bootstrap and daemon startup locking behavior.
|
||||
- Updated website hero copy to emphasize local execution.
|
||||
|
||||
### Fixed
|
||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||
- Fixed Claude session identity preservation when reloading existing agents.
|
||||
- Fixed combobox option behavior and related interactions.
|
||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||
|
||||
## [0.1.7] - 2026-02-16
|
||||
### Added
|
||||
- Improved agent workspace flows with better directory suggestions.
|
||||
|
||||
@@ -139,6 +139,7 @@ npm run android:production
|
||||
### Cloud build + submit (EAS Workflows)
|
||||
|
||||
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
|
||||
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
|
||||
|
||||
That workflow does:
|
||||
- Build iOS with the `production` profile
|
||||
@@ -182,7 +183,7 @@ npm run release:patch
|
||||
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
|
||||
npm run release:check
|
||||
npm run release:publish
|
||||
npm run release:push # pushes HEAD and current version tag (triggers desktop + EAS mobile workflows)
|
||||
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
|
||||
```
|
||||
|
||||
Notes:
|
||||
@@ -196,6 +197,7 @@ Notes:
|
||||
Release completion checklist:
|
||||
- `npm run release:patch` completes successfully.
|
||||
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
|
||||
- GitHub `Android APK Release` workflow for the same tag is green.
|
||||
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
|
||||
|
||||
## Orchestrator Mode
|
||||
|
||||
54
package-lock.json
generated
54
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -14714,7 +14714,9 @@
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"license": "MIT"
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
@@ -14779,6 +14781,12 @@
|
||||
"version": "2.0.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"funding": [
|
||||
@@ -14825,6 +14833,16 @@
|
||||
"version": "0.4.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.2.0-beta.11",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.11.tgz",
|
||||
"integrity": "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.26",
|
||||
"license": "MIT"
|
||||
@@ -20344,7 +20362,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -20352,7 +20370,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.8",
|
||||
"@getpaseo/server": "0.1.13",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
@@ -20456,11 +20474,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.8",
|
||||
"@getpaseo/server": "0.1.8",
|
||||
"@getpaseo/relay": "0.1.13",
|
||||
"@getpaseo/server": "0.1.13",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -20510,14 +20528,14 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -20533,12 +20551,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.8",
|
||||
"@getpaseo/relay": "0.1.13",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -20558,7 +20576,7 @@
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "^1.0.0",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
"pino": "^10.2.0",
|
||||
@@ -20802,14 +20820,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/node-pty": {
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nan": "^2.17.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -20890,7 +20900,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -30,7 +30,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/server": "0.1.8",
|
||||
"@getpaseo/server": "0.1.13",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
|
||||
@@ -240,8 +240,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
|
||||
// Mobile: full-screen overlay with gesture
|
||||
const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
|
||||
// Mobile: full-screen overlay with gesture.
|
||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
|
||||
@@ -325,8 +325,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
|
||||
// Render mobile sidebar
|
||||
// On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll
|
||||
const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
|
||||
// On web, keep the overlay interactive only while the sidebar is open.
|
||||
// This preserves swipe/scroll behavior without blocking taps when closed.
|
||||
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||
if (isMobile) {
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useDaemonConnections } from "./daemon-connections-context";
|
||||
import type { ActiveConnection } from "./daemon-connections-context";
|
||||
import {
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
type SessionState,
|
||||
type DaemonConnectionSnapshot,
|
||||
} from "@/stores/session-store";
|
||||
@@ -850,14 +851,7 @@ export function SessionProvider({
|
||||
useEffect(() => {
|
||||
if (!connectionSnapshot.isConnected) {
|
||||
hasBootstrappedAgentUpdatesRef.current = false;
|
||||
const subscriptionId = agentUpdatesSubscriptionIdRef.current;
|
||||
if (subscriptionId && client) {
|
||||
try {
|
||||
client.unsubscribeAgentUpdates(subscriptionId);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
pendingAgentUpdatesRef.current.clear();
|
||||
agentUpdatesSubscriptionIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -866,26 +860,84 @@ export function SessionProvider({
|
||||
}
|
||||
hasBootstrappedAgentUpdatesRef.current = true;
|
||||
|
||||
try {
|
||||
if (!agentUpdatesSubscriptionIdRef.current) {
|
||||
agentUpdatesSubscriptionIdRef.current = client.subscribeAgentUpdates({
|
||||
subscriptionId: `app:${serverId}`,
|
||||
filter: { labels: { ui: "true" } },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Session] subscribeAgentUpdates failed", { serverId, err });
|
||||
}
|
||||
let cancelled = false;
|
||||
const requestedSubscriptionId = `app:${serverId}`;
|
||||
|
||||
// Session bootstrap is now fully event-driven for agent lists.
|
||||
setInitializingAgents(serverId, new Map());
|
||||
setHasHydratedAgents(serverId, true);
|
||||
updateConnectionStatus(serverId, {
|
||||
status: "online",
|
||||
lastOnlineAt: new Date().toISOString(),
|
||||
agentListReady: true,
|
||||
});
|
||||
}, [connectionSnapshot.isConnected, client, serverId, setHasHydratedAgents, updateConnectionStatus]);
|
||||
const bootstrapAgentDirectory = async () => {
|
||||
try {
|
||||
const payload = await client.fetchAgents({
|
||||
filter: { labels: { ui: "true" } },
|
||||
subscribe: { subscriptionId: requestedSubscriptionId },
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
agentUpdatesSubscriptionIdRef.current =
|
||||
payload.subscriptionId ?? requestedSubscriptionId;
|
||||
|
||||
const nextAgents = new Map<string, Agent>();
|
||||
const nextPendingPermissions = new Map<
|
||||
string,
|
||||
{ key: string; agentId: string; request: AgentPermissionRequest }
|
||||
>();
|
||||
const nextStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const agent = {
|
||||
...normalizeAgentSnapshot(entry.agent, serverId),
|
||||
projectPlacement: entry.project,
|
||||
};
|
||||
nextAgents.set(agent.id, agent);
|
||||
nextStatuses.set(agent.id, agent.status);
|
||||
|
||||
for (const request of agent.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(agent.id, request);
|
||||
nextPendingPermissions.set(key, { key, agentId: agent.id, request });
|
||||
}
|
||||
}
|
||||
|
||||
previousAgentStatusRef.current = nextStatuses;
|
||||
pendingAgentUpdatesRef.current.clear();
|
||||
setAgents(serverId, nextAgents);
|
||||
for (const agent of nextAgents.values()) {
|
||||
setAgentLastActivity(agent.id, agent.lastActivityAt);
|
||||
}
|
||||
setPendingPermissions(serverId, nextPendingPermissions);
|
||||
setInitializingAgents(serverId, new Map());
|
||||
setHasHydratedAgents(serverId, true);
|
||||
updateConnectionStatus(serverId, {
|
||||
status: "online",
|
||||
lastOnlineAt: new Date().toISOString(),
|
||||
agentListReady: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
hasBootstrappedAgentUpdatesRef.current = false;
|
||||
pendingAgentUpdatesRef.current.clear();
|
||||
agentUpdatesSubscriptionIdRef.current = null;
|
||||
console.error("[Session] fetchAgents bootstrap failed", { serverId, err });
|
||||
}
|
||||
};
|
||||
|
||||
void bootstrapAgentDirectory();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
connectionSnapshot.isConnected,
|
||||
client,
|
||||
serverId,
|
||||
setAgentLastActivity,
|
||||
setAgents,
|
||||
setHasHydratedAgents,
|
||||
setInitializingAgents,
|
||||
setPendingPermissions,
|
||||
updateConnectionStatus,
|
||||
]);
|
||||
|
||||
// Daemon message handlers - directly update Zustand store
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -22,8 +22,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.8",
|
||||
"@getpaseo/server": "0.1.8",
|
||||
"@getpaseo/relay": "0.1.13",
|
||||
"@getpaseo/server": "0.1.13",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Command } from 'commander'
|
||||
import type { AgentSnapshotPayload } from '@getpaseo/server'
|
||||
import {
|
||||
getStructuredAgentResponse,
|
||||
StructuredAgentResponseError,
|
||||
type AgentSnapshotPayload,
|
||||
} from '@getpaseo/server'
|
||||
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
|
||||
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -105,98 +109,55 @@ function loadOutputSchema(value: string): Record<string, unknown> {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
function extractFirstJsonObject(text: string): string | null {
|
||||
const source = text.trim()
|
||||
if (!source) {
|
||||
return null
|
||||
class StructuredRunStatusError extends Error {
|
||||
readonly kind: 'timeout' | 'permission' | 'error' | 'empty'
|
||||
|
||||
constructor(kind: 'timeout' | 'permission' | 'error' | 'empty', message: string) {
|
||||
super(message)
|
||||
this.name = 'StructuredRunStatusError'
|
||||
this.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>
|
||||
|
||||
export interface StructuredResponseTimelineClient {
|
||||
fetchAgentTimeline: ConnectedDaemonClient['fetchAgentTimeline']
|
||||
}
|
||||
|
||||
export async function resolveStructuredResponseMessage(options: {
|
||||
client: StructuredResponseTimelineClient
|
||||
agentId: string
|
||||
lastMessage: string | null
|
||||
}): Promise<string | null> {
|
||||
const direct = options.lastMessage?.trim()
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
|
||||
const startIndexes: number[] = []
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
if (source[i] === '{') {
|
||||
startIndexes.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
for (const start of startIndexes) {
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i]!
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = false
|
||||
}
|
||||
try {
|
||||
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
|
||||
direction: 'tail',
|
||||
projection: 'projected',
|
||||
limit: 200,
|
||||
})
|
||||
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = timeline.entries[index]
|
||||
if (!entry || entry.item.type !== 'assistant_message') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '{') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (ch === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
const candidate = source.slice(start, i + 1).trim()
|
||||
try {
|
||||
JSON.parse(candidate)
|
||||
return candidate
|
||||
} catch {
|
||||
// Keep scanning.
|
||||
}
|
||||
}
|
||||
const text = entry.item.text.trim()
|
||||
if (text.length > 0) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Leave empty; caller will surface a consistent structured-output failure message.
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseStructuredOutput(lastMessage: string): Record<string, unknown> {
|
||||
const trimmed = lastMessage.trim()
|
||||
const fenced = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
|
||||
const jsonText = fenced?.[1]?.trim() ?? extractFirstJsonObject(trimmed) ?? trimmed
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(jsonText)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent response is not valid JSON',
|
||||
details: message,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent response JSON must be an object',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
|
||||
return {
|
||||
...agentRunSchema,
|
||||
@@ -318,6 +279,107 @@ export async function runRunCommand(
|
||||
labels['ui'] = 'true'
|
||||
}
|
||||
|
||||
if (outputSchema) {
|
||||
let structuredAgent: AgentSnapshotPayload | null = null
|
||||
|
||||
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
|
||||
if (!structuredAgent) {
|
||||
structuredAgent = await client.createAgent({
|
||||
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
|
||||
cwd,
|
||||
title: options.name,
|
||||
modeId: options.mode,
|
||||
model: options.model,
|
||||
initialPrompt: structuredPrompt,
|
||||
images,
|
||||
git,
|
||||
worktreeName: options.worktree,
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
})
|
||||
} else {
|
||||
await client.sendMessage(structuredAgent.id, structuredPrompt)
|
||||
}
|
||||
|
||||
const state = await client.waitForFinish(structuredAgent.id, 10 * 60 * 1000)
|
||||
if (state.status === 'timeout') {
|
||||
throw new StructuredRunStatusError('timeout', 'Timed out waiting for structured output')
|
||||
}
|
||||
if (state.status === 'permission') {
|
||||
throw new StructuredRunStatusError(
|
||||
'permission',
|
||||
'Agent is waiting for permission before producing structured output'
|
||||
)
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
throw new StructuredRunStatusError(
|
||||
'error',
|
||||
state.error ?? 'Agent failed before producing structured output'
|
||||
)
|
||||
}
|
||||
|
||||
const lastMessage = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: structuredAgent.id,
|
||||
lastMessage: state.lastMessage,
|
||||
})
|
||||
if (!lastMessage) {
|
||||
throw new StructuredRunStatusError(
|
||||
'empty',
|
||||
'Agent finished without a structured output message'
|
||||
)
|
||||
}
|
||||
|
||||
return lastMessage
|
||||
}
|
||||
|
||||
let output: Record<string, unknown>
|
||||
try {
|
||||
output = await getStructuredAgentResponse<Record<string, unknown>>({
|
||||
caller: callStructuredTurn,
|
||||
prompt,
|
||||
schema: outputSchema,
|
||||
schemaName: 'RunOutput',
|
||||
maxRetries: 2,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof StructuredRunStatusError) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: err.message,
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (err instanceof StructuredAgentResponseError) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent response did not match the required output schema',
|
||||
details:
|
||||
err.validationErrors.length > 0
|
||||
? err.validationErrors.join('\n')
|
||||
: err.lastResponse || 'No response',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!structuredAgent) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent finished without a structured output message',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
await client.close()
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: toRunResult(structuredAgent, 'completed'),
|
||||
schema: structuredRunSchema(output),
|
||||
}
|
||||
}
|
||||
|
||||
// Create the agent
|
||||
const agent = await client.createAgent({
|
||||
provider: (options.provider as 'claude' | 'codex' | 'opencode') ?? 'claude',
|
||||
@@ -326,59 +388,12 @@ export async function runRunCommand(
|
||||
modeId: options.mode,
|
||||
model: options.model,
|
||||
initialPrompt: prompt,
|
||||
outputSchema,
|
||||
images,
|
||||
git,
|
||||
worktreeName: options.worktree,
|
||||
labels: Object.keys(labels).length > 0 ? labels : undefined,
|
||||
})
|
||||
|
||||
if (outputSchema) {
|
||||
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
|
||||
|
||||
if (state.status === 'timeout') {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Timed out waiting for structured output',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (state.status === 'permission') {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent is waiting for permission before producing structured output',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: state.error ?? 'Agent failed before producing structured output',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const lastMessage = state.lastMessage?.trim()
|
||||
if (!lastMessage) {
|
||||
const error: CommandError = {
|
||||
code: 'OUTPUT_SCHEMA_FAILED',
|
||||
message: 'Agent finished without a structured output message',
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const output = parseStructuredOutput(lastMessage)
|
||||
await client.close()
|
||||
|
||||
return {
|
||||
type: 'single',
|
||||
data: toRunResult(agent, 'completed'),
|
||||
schema: structuredRunSchema(output),
|
||||
}
|
||||
}
|
||||
|
||||
// Default run behavior is foreground: wait for completion unless --detach is set.
|
||||
if (!options.detach) {
|
||||
const state = await client.waitForFinish(agent.id, 10 * 60 * 1000)
|
||||
|
||||
@@ -62,7 +62,6 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
client.subscribeAgentUpdates({ subscriptionId: `cli:${process.pid}` })
|
||||
return client
|
||||
} catch (err) {
|
||||
// Clear the timeout on error too
|
||||
|
||||
97
packages/cli/tests/21-run-output-schema-helper.test.ts
Normal file
97
packages/cli/tests/21-run-output-schema-helper.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
resolveStructuredResponseMessage,
|
||||
type StructuredResponseTimelineClient,
|
||||
} from '../src/commands/agent/run.ts'
|
||||
|
||||
type TimelineEntry = {
|
||||
item: {
|
||||
type: string
|
||||
text?: string
|
||||
}
|
||||
}
|
||||
|
||||
function createClient(options: {
|
||||
entries?: TimelineEntry[]
|
||||
throwOnFetch?: boolean
|
||||
onFetch?: () => void
|
||||
}): StructuredResponseTimelineClient {
|
||||
return {
|
||||
fetchAgentTimeline: async () => {
|
||||
options.onFetch?.()
|
||||
if (options.throwOnFetch) {
|
||||
throw new Error('timeline unavailable')
|
||||
}
|
||||
return {
|
||||
entries: options.entries ?? [],
|
||||
} as Awaited<ReturnType<StructuredResponseTimelineClient['fetchAgentTimeline']>>
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Run Output Schema Helper Tests ===\n')
|
||||
|
||||
// Test 1: Direct lastMessage is returned without timeline fetch.
|
||||
{
|
||||
let fetchCount = 0
|
||||
const client = createClient({ onFetch: () => fetchCount += 1 })
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-1',
|
||||
lastMessage: ' {"summary":"ok"} ',
|
||||
})
|
||||
assert.strictEqual(result, '{"summary":"ok"}')
|
||||
assert.strictEqual(fetchCount, 0, 'should not fetch timeline when lastMessage exists')
|
||||
console.log('✓ returns direct lastMessage when present')
|
||||
}
|
||||
|
||||
// Test 2: Falls back to latest assistant message from timeline.
|
||||
{
|
||||
const client = createClient({
|
||||
entries: [
|
||||
{ item: { type: 'user_message', text: 'prompt' } },
|
||||
{ item: { type: 'assistant_message', text: '' } },
|
||||
{ item: { type: 'assistant_message', text: ' {"summary":"from timeline"} ' } },
|
||||
],
|
||||
})
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-2',
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, '{"summary":"from timeline"}')
|
||||
console.log('✓ falls back to latest assistant timeline entry')
|
||||
}
|
||||
|
||||
// Test 3: Returns null when timeline has no assistant messages.
|
||||
{
|
||||
const client = createClient({
|
||||
entries: [
|
||||
{ item: { type: 'user_message', text: 'prompt' } },
|
||||
{ item: { type: 'reasoning', text: 'thinking' } },
|
||||
],
|
||||
})
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-3',
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, null)
|
||||
console.log('✓ returns null when no assistant messages exist')
|
||||
}
|
||||
|
||||
// Test 4: Returns null if timeline fetch fails.
|
||||
{
|
||||
const client = createClient({ throwOnFetch: true })
|
||||
const result = await resolveStructuredResponseMessage({
|
||||
client,
|
||||
agentId: 'agent-4',
|
||||
lastMessage: null,
|
||||
})
|
||||
assert.strictEqual(result, null)
|
||||
console.log('✓ returns null when timeline fetch throws')
|
||||
}
|
||||
|
||||
console.log('\n=== All helper tests passed ===')
|
||||
139
packages/cli/tests/e2e/run-output-schema.test.ts
Normal file
139
packages/cli/tests/e2e/run-output-schema.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from 'node:assert'
|
||||
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
|
||||
|
||||
interface E2EContext extends TestDaemonContext {
|
||||
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}>
|
||||
}
|
||||
|
||||
const schema = JSON.stringify({
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['summary'],
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
const impossibleSchema = JSON.stringify({
|
||||
type: 'object',
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
},
|
||||
required: ['summary'],
|
||||
allOf: [
|
||||
{ properties: { summary: { type: 'string' } } },
|
||||
{ properties: { summary: { type: 'number' } } },
|
||||
],
|
||||
})
|
||||
|
||||
let ctx: E2EContext
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
ctx = await createE2ETestContext({ timeout: 180000 })
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (ctx) {
|
||||
await ctx.stop()
|
||||
}
|
||||
}
|
||||
|
||||
async function runProviderCase(input: {
|
||||
provider: 'claude' | 'codex' | 'opencode'
|
||||
mode: string
|
||||
model: string
|
||||
}): Promise<void> {
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'run',
|
||||
'--provider',
|
||||
input.provider,
|
||||
'--mode',
|
||||
input.mode,
|
||||
'--model',
|
||||
input.model,
|
||||
'--output-schema',
|
||||
schema,
|
||||
`Return valid JSON with a short summary for provider ${input.provider}.`,
|
||||
],
|
||||
{ timeout: 180000 }
|
||||
)
|
||||
|
||||
assert.strictEqual(
|
||||
result.exitCode,
|
||||
0,
|
||||
`expected ${input.provider} run to succeed, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
)
|
||||
|
||||
const parsed = JSON.parse(result.stdout.trim()) as { summary?: unknown }
|
||||
assert.strictEqual(
|
||||
typeof parsed.summary,
|
||||
'string',
|
||||
`expected ${input.provider} output to contain string summary, got: ${result.stdout}`
|
||||
)
|
||||
assert(parsed.summary && parsed.summary.length > 0, 'summary must not be empty')
|
||||
}
|
||||
|
||||
async function test_all_providers_return_structured_output(): Promise<void> {
|
||||
await runProviderCase({
|
||||
provider: 'claude',
|
||||
mode: 'bypassPermissions',
|
||||
model: 'haiku',
|
||||
})
|
||||
await runProviderCase({
|
||||
provider: 'codex',
|
||||
mode: 'full-access',
|
||||
model: 'gpt-5.3-codex',
|
||||
})
|
||||
await runProviderCase({
|
||||
provider: 'opencode',
|
||||
mode: 'default',
|
||||
model: 'opencode/kimi-k2.5-free',
|
||||
})
|
||||
}
|
||||
|
||||
async function test_schema_validation_is_enforced(): Promise<void> {
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
'run',
|
||||
'--provider',
|
||||
'claude',
|
||||
'--mode',
|
||||
'bypassPermissions',
|
||||
'--model',
|
||||
'haiku',
|
||||
'--output-schema',
|
||||
impossibleSchema,
|
||||
'Return exactly {"summary":"ok"} and nothing else.',
|
||||
],
|
||||
{ timeout: 180000 }
|
||||
)
|
||||
|
||||
assert.notStrictEqual(result.exitCode, 0, 'expected impossible schema to fail')
|
||||
const output = `${result.stdout}\n${result.stderr}`
|
||||
assert(
|
||||
output.includes('OUTPUT_SCHEMA_FAILED'),
|
||||
`expected OUTPUT_SCHEMA_FAILED for impossible schema\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
await setup()
|
||||
await test_all_providers_return_structured_output()
|
||||
await test_schema_validation_is_enforced()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Tauri wrapper)",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -9,6 +9,7 @@
|
||||
"types": "./dist/server/server/exports.d.ts",
|
||||
"files": [
|
||||
"dist/server",
|
||||
"dist/src",
|
||||
"dist/scripts",
|
||||
"README.md",
|
||||
".env.example",
|
||||
@@ -52,10 +53,10 @@
|
||||
"test:e2e:mobile": "playwright test --project='Mobile Chrome'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/relay": "0.1.8",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.13",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -75,7 +76,7 @@
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"lezer-elixir": "^1.1.2",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "^1.0.0",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
"pino": "^10.2.0",
|
||||
@@ -92,13 +93,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"playwright": "^1.56.1",
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/ws": "^8.5.8",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"playwright": "^1.56.1",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
@@ -49,7 +49,9 @@ async function runVoiceRoundTrip(params: {
|
||||
}): Promise<RoundTripResult> {
|
||||
const client = new DaemonClient({ url: `${params.daemonUrl}/ws` });
|
||||
await client.connect();
|
||||
client.subscribeAgentUpdates({ subscriptionId: `voice-e2e-${randomUUID()}` });
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: `voice-e2e-${randomUUID()}` },
|
||||
});
|
||||
|
||||
const mode = await client.setVoiceMode(true, params.voiceAgentId);
|
||||
if (!mode.accepted) {
|
||||
|
||||
@@ -48,7 +48,7 @@ async function main(): Promise<void> {
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
client.subscribeAgentUpdates({ subscriptionId: "voice-debug" });
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "voice-debug" } });
|
||||
|
||||
const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-roundtrip-debug-"));
|
||||
const voiceAgent = await client.createAgent({
|
||||
|
||||
@@ -535,6 +535,7 @@ describe("DaemonClient", () => {
|
||||
{ key: "created_at", direction: "desc" },
|
||||
],
|
||||
page: { limit: 25, cursor: "cursor-1" },
|
||||
subscribe: { subscriptionId: "sub-1" },
|
||||
});
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
@@ -549,6 +550,7 @@ describe("DaemonClient", () => {
|
||||
direction: "asc" | "desc";
|
||||
}>;
|
||||
page?: { limit: number; cursor?: string };
|
||||
subscribe?: { subscriptionId?: string };
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("fetch_agents_request");
|
||||
@@ -557,6 +559,7 @@ describe("DaemonClient", () => {
|
||||
{ key: "created_at", direction: "desc" },
|
||||
]);
|
||||
expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
||||
expect(request.message.subscribe).toEqual({ subscriptionId: "sub-1" });
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
@@ -565,6 +568,7 @@ describe("DaemonClient", () => {
|
||||
type: "fetch_agents_response",
|
||||
payload: {
|
||||
requestId: request.message.requestId,
|
||||
subscriptionId: "sub-1",
|
||||
entries: [],
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
@@ -578,6 +582,7 @@ describe("DaemonClient", () => {
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: request.message.requestId,
|
||||
subscriptionId: "sub-1",
|
||||
entries: [],
|
||||
pageInfo: {
|
||||
nextCursor: null,
|
||||
|
||||
@@ -350,10 +350,6 @@ export class DaemonClient {
|
||||
private connectReject: ((error: Error) => void) | null = null;
|
||||
private lastErrorValue: string | null = null;
|
||||
private connectionState: ConnectionState = { status: "idle" };
|
||||
private agentUpdateSubscriptions = new Map<
|
||||
string,
|
||||
{ labels?: Record<string, string>; agentId?: string } | undefined
|
||||
>();
|
||||
private checkoutDiffSubscriptions = new Map<
|
||||
string,
|
||||
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
|
||||
@@ -475,7 +471,6 @@ export class DaemonClient {
|
||||
this.lastErrorValue = null;
|
||||
this.reconnectAttempt = 0;
|
||||
this.updateConnectionState({ status: "connected" });
|
||||
this.resubscribeAgentUpdates();
|
||||
this.resubscribeCheckoutDiffSubscriptions();
|
||||
this.resubscribeTerminalDirectorySubscriptions();
|
||||
this.flushPendingSendQueue();
|
||||
@@ -997,6 +992,7 @@ export class DaemonClient {
|
||||
...(options?.filter ? { filter: options.filter } : {}),
|
||||
...(options?.sort ? { sort: options.sort } : {}),
|
||||
...(options?.page ? { page: options.page } : {}),
|
||||
...(options?.subscribe ? { subscribe: options.subscribe } : {}),
|
||||
});
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
@@ -1043,44 +1039,6 @@ export class DaemonClient {
|
||||
return payload.agent;
|
||||
}
|
||||
|
||||
subscribeAgentUpdates(options?: {
|
||||
subscriptionId?: string;
|
||||
filter?: { labels?: Record<string, string>; agentId?: string };
|
||||
}): string {
|
||||
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
||||
this.agentUpdateSubscriptions.set(subscriptionId, options?.filter);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "subscribe_agent_updates",
|
||||
subscriptionId,
|
||||
...(options?.filter ? { filter: options.filter } : {}),
|
||||
});
|
||||
this.sendSessionMessage(message);
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
unsubscribeAgentUpdates(subscriptionId: string): void {
|
||||
this.agentUpdateSubscriptions.delete(subscriptionId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "unsubscribe_agent_updates",
|
||||
subscriptionId,
|
||||
});
|
||||
this.sendSessionMessage(message);
|
||||
}
|
||||
|
||||
private resubscribeAgentUpdates(): void {
|
||||
if (this.agentUpdateSubscriptions.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const [subscriptionId, filter] of this.agentUpdateSubscriptions) {
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "subscribe_agent_updates",
|
||||
subscriptionId,
|
||||
...(filter ? { filter } : {}),
|
||||
});
|
||||
this.sendSessionMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
private resubscribeCheckoutDiffSubscriptions(): void {
|
||||
if (this.checkoutDiffSubscriptions.size === 0) {
|
||||
return;
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponse,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent-response-loop.js";
|
||||
import { validateBranchSlug } from "../../utils/worktree.js";
|
||||
import {
|
||||
@@ -14,12 +16,8 @@ import {
|
||||
type CheckoutStatusResult,
|
||||
} from "../../utils/checkout-git.js";
|
||||
|
||||
const AUTO_GEN_PROVIDER = "codex" as const;
|
||||
const AUTO_GEN_MODEL = "gpt-5.1-codex-mini";
|
||||
const AUTO_GEN_REASONING_EFFORT = "low";
|
||||
|
||||
export type AgentMetadataGeneratorDeps = {
|
||||
generateStructuredAgentResponse?: typeof generateStructuredAgentResponse;
|
||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||
getCheckoutStatus?: typeof getCheckoutStatus;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
};
|
||||
@@ -148,7 +146,9 @@ export async function generateAndApplyAgentMetadata(
|
||||
return;
|
||||
}
|
||||
|
||||
const generator = options.deps?.generateStructuredAgentResponse ?? generateStructuredAgentResponse;
|
||||
const generator =
|
||||
options.deps?.generateStructuredAgentResponseWithFallback ??
|
||||
generateStructuredAgentResponseWithFallback;
|
||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
||||
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
||||
|
||||
@@ -157,21 +157,22 @@ export async function generateAndApplyAgentMetadata(
|
||||
try {
|
||||
result = await generator({
|
||||
manager: options.agentManager,
|
||||
agentConfig: {
|
||||
provider: AUTO_GEN_PROVIDER,
|
||||
model: AUTO_GEN_MODEL,
|
||||
thinkingOptionId: AUTO_GEN_REASONING_EFFORT,
|
||||
cwd: options.cwd,
|
||||
title: "Agent metadata generator",
|
||||
internal: true,
|
||||
},
|
||||
cwd: options.cwd,
|
||||
prompt: buildPrompt(needs),
|
||||
schema,
|
||||
schemaName: "AgentMetadata",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
agentConfigOverrides: {
|
||||
title: "Agent metadata generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Structured metadata generation failed"
|
||||
|
||||
@@ -2,9 +2,12 @@ import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getStructuredAgentResponse,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
type AgentCaller,
|
||||
} from "./agent-response-loop.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
|
||||
function createScriptedCaller(responses: string[]) {
|
||||
const prompts: string[] = [];
|
||||
@@ -142,3 +145,134 @@ describe("getStructuredAgentResponse", () => {
|
||||
expect(result).toEqual({ value: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
const schema = z.object({ summary: z.string() });
|
||||
|
||||
function createManager(availability: Array<{ provider: string; available: boolean; error: string | null }>) {
|
||||
return {
|
||||
listProviderAvailability: async () => availability,
|
||||
} as unknown as AgentManager;
|
||||
}
|
||||
|
||||
it("uses the first available provider in the waterfall", async () => {
|
||||
const calls: Array<{ provider: string; model?: string }> = [];
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: true, error: null },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: true, error: null },
|
||||
]);
|
||||
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
],
|
||||
runner: async (options) => {
|
||||
calls.push({
|
||||
provider: options.agentConfig.provider,
|
||||
model: options.agentConfig.model ?? undefined,
|
||||
});
|
||||
return { summary: "ok" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([{ provider: "claude", model: "haiku" }]);
|
||||
});
|
||||
|
||||
it("skips unavailable providers and uses the next available one", async () => {
|
||||
const calls: Array<{ provider: string; model?: string }> = [];
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: false, error: "missing auth" },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: true, error: null },
|
||||
]);
|
||||
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
],
|
||||
runner: async (options) => {
|
||||
calls.push({
|
||||
provider: options.agentConfig.provider,
|
||||
model: options.agentConfig.model ?? undefined,
|
||||
});
|
||||
return { summary: "ok" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([{ provider: "codex", model: "gpt-5.1-codex-mini" }]);
|
||||
});
|
||||
|
||||
it("falls back when an available provider fails", async () => {
|
||||
const calls: Array<{ provider: string; model?: string }> = [];
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: true, error: null },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: true, error: null },
|
||||
]);
|
||||
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
],
|
||||
runner: async (options) => {
|
||||
calls.push({
|
||||
provider: options.agentConfig.provider,
|
||||
model: options.agentConfig.model ?? undefined,
|
||||
});
|
||||
if (options.agentConfig.provider === "claude") {
|
||||
throw new Error("claude failed");
|
||||
}
|
||||
return { summary: "ok" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ summary: "ok" });
|
||||
expect(calls).toEqual([
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws a fallback error when all providers are unavailable or fail", async () => {
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: false, error: "missing auth" },
|
||||
{ provider: "codex", available: true, error: null },
|
||||
{ provider: "opencode", available: false, error: "not installed" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
|
||||
],
|
||||
runner: async () => {
|
||||
throw new Error("failed");
|
||||
},
|
||||
})
|
||||
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||
|
||||
@@ -21,6 +21,43 @@ export class StructuredAgentResponseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type StructuredGenerationProvider = {
|
||||
provider: AgentProvider;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
};
|
||||
|
||||
export type StructuredGenerationAttempt = {
|
||||
provider: AgentProvider;
|
||||
model: string | null;
|
||||
available: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export class StructuredAgentFallbackError extends Error {
|
||||
readonly attempts: StructuredGenerationAttempt[];
|
||||
|
||||
constructor(attempts: StructuredGenerationAttempt[]) {
|
||||
const summary = attempts
|
||||
.map((attempt) => {
|
||||
const modelSuffix = attempt.model ? ` (${attempt.model})` : "";
|
||||
if (!attempt.available) {
|
||||
return `${attempt.provider}${modelSuffix}: unavailable${attempt.error ? ` (${attempt.error})` : ""}`;
|
||||
}
|
||||
return `${attempt.provider}${modelSuffix}: failed${attempt.error ? ` (${attempt.error})` : ""}`;
|
||||
})
|
||||
.join("; ");
|
||||
|
||||
super(
|
||||
summary.length > 0
|
||||
? `Structured generation failed for all providers: ${summary}`
|
||||
: "Structured generation failed for all providers"
|
||||
);
|
||||
this.name = "StructuredAgentFallbackError";
|
||||
this.attempts = attempts;
|
||||
}
|
||||
}
|
||||
|
||||
export interface StructuredAgentResponseOptions<T> {
|
||||
caller: AgentCaller;
|
||||
prompt: string;
|
||||
@@ -39,6 +76,27 @@ export interface StructuredAgentGenerationOptions<T> {
|
||||
schemaName?: string;
|
||||
}
|
||||
|
||||
export interface StructuredAgentGenerationWithFallbackOptions<T> {
|
||||
manager: AgentManager;
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
schema: z.ZodType<T> | JsonSchema;
|
||||
providers: readonly StructuredGenerationProvider[];
|
||||
agentConfigOverrides?: Omit<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "model" | "thinkingOptionId"
|
||||
>;
|
||||
maxRetries?: number;
|
||||
schemaName?: string;
|
||||
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
|
||||
}
|
||||
|
||||
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
|
||||
] as const;
|
||||
|
||||
interface SchemaValidator<T> {
|
||||
jsonSchema: JsonSchema;
|
||||
validate: (value: unknown) => { ok: true; value: T } | { ok: false; errors: string[] };
|
||||
@@ -293,3 +351,82 @@ export async function generateStructuredAgentResponse<T>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
options: StructuredAgentGenerationWithFallbackOptions<T>
|
||||
): Promise<T> {
|
||||
const {
|
||||
manager,
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
providers,
|
||||
agentConfigOverrides,
|
||||
maxRetries,
|
||||
schemaName,
|
||||
runner,
|
||||
} = options;
|
||||
|
||||
if (providers.length === 0) {
|
||||
throw new StructuredAgentFallbackError([]);
|
||||
}
|
||||
|
||||
const runStructured =
|
||||
runner ??
|
||||
((input: StructuredAgentGenerationOptions<T>) =>
|
||||
generateStructuredAgentResponse<T>(input));
|
||||
const availability = await manager.listProviderAvailability();
|
||||
const availabilityByProvider = new Map(
|
||||
availability.map((entry) => [entry.provider, entry])
|
||||
);
|
||||
const attempts: StructuredGenerationAttempt[] = [];
|
||||
|
||||
for (const candidate of providers) {
|
||||
const availabilityEntry = availabilityByProvider.get(candidate.provider);
|
||||
if (availabilityEntry && !availabilityEntry.available) {
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model ?? null,
|
||||
available: false,
|
||||
error: availabilityEntry.error ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runStructured({
|
||||
manager,
|
||||
prompt,
|
||||
schema,
|
||||
maxRetries,
|
||||
schemaName,
|
||||
agentConfig: {
|
||||
...agentConfigOverrides,
|
||||
provider: candidate.provider,
|
||||
cwd,
|
||||
...(candidate.model ? { model: candidate.model } : {}),
|
||||
...(candidate.thinkingOptionId
|
||||
? { thinkingOptionId: candidate.thinkingOptionId }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model ?? null,
|
||||
available: true,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new StructuredAgentFallbackError(attempts);
|
||||
}
|
||||
|
||||
@@ -291,7 +291,9 @@ describe("daemon client E2E", () => {
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
ctx.client.subscribeAgentUpdates();
|
||||
await ctx.client.fetchAgents({
|
||||
subscribe: { subscriptionId: "daemon-client-lifecycle" },
|
||||
});
|
||||
|
||||
const agentUpdatePromise = waitForSignal(15000, (resolve) => {
|
||||
const unsubscribe = ctx.client.on("agent_update", (message) => {
|
||||
|
||||
@@ -143,7 +143,9 @@ describe("daemon E2E", () => {
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
ctx.client.subscribeAgentUpdates();
|
||||
await ctx.client.fetchAgents({
|
||||
subscribe: { subscriptionId: "agent-operations-cancel" },
|
||||
});
|
||||
|
||||
// Create Codex agent
|
||||
const agent = await ctx.client.createAgent({
|
||||
@@ -217,7 +219,9 @@ describe("daemon E2E", () => {
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
ctx.client.subscribeAgentUpdates();
|
||||
await ctx.client.fetchAgents({
|
||||
subscribe: { subscriptionId: "agent-operations-mode" },
|
||||
});
|
||||
|
||||
// Create a Codex agent with default mode ("auto")
|
||||
const agent = await ctx.client.createAgent({
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("daemon E2E", () => {
|
||||
unsubscribe = ctx.client.subscribeRawMessages((message) => {
|
||||
messages.push(message);
|
||||
});
|
||||
ctx.client.subscribeAgentUpdates();
|
||||
await ctx.client.fetchAgents({ subscribe: { subscriptionId: "live-preferences" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -32,8 +32,8 @@ describe("daemon E2E (real claude) - send while running recovery", () => {
|
||||
try {
|
||||
await primary.connect();
|
||||
await secondary.connect();
|
||||
primary.subscribeAgentUpdates({ subscriptionId: "primary" });
|
||||
secondary.subscribeAgentUpdates({ subscriptionId: "secondary" });
|
||||
await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } });
|
||||
await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } });
|
||||
|
||||
const agent = await primary.createAgent({
|
||||
cwd,
|
||||
@@ -67,7 +67,9 @@ describe("daemon E2E (real claude) - send while running recovery", () => {
|
||||
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await reconnected.connect();
|
||||
reconnected.subscribeAgentUpdates({ subscriptionId: "reconnected" });
|
||||
await reconnected.fetchAgents({
|
||||
subscribe: { subscriptionId: "reconnected" },
|
||||
});
|
||||
|
||||
reconnected.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
|
||||
|
||||
@@ -32,8 +32,8 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
|
||||
try {
|
||||
await primary.connect();
|
||||
await secondary.connect();
|
||||
primary.subscribeAgentUpdates({ subscriptionId: "primary" });
|
||||
secondary.subscribeAgentUpdates({ subscriptionId: "secondary" });
|
||||
await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } });
|
||||
await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } });
|
||||
|
||||
const agent = await primary.createAgent({
|
||||
cwd,
|
||||
@@ -64,7 +64,9 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
|
||||
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await reconnected.connect();
|
||||
reconnected.subscribeAgentUpdates({ subscriptionId: "reconnected" });
|
||||
await reconnected.fetchAgents({
|
||||
subscribe: { subscriptionId: "reconnected" },
|
||||
});
|
||||
|
||||
reconnected.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
|
||||
|
||||
@@ -34,6 +34,20 @@ export type {
|
||||
|
||||
// Agent activity curator for CLI logs
|
||||
export { curateAgentActivity } from "./agent/activity-curator.js";
|
||||
export {
|
||||
getStructuredAgentResponse,
|
||||
StructuredAgentResponseError,
|
||||
StructuredAgentFallbackError,
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
type AgentCaller,
|
||||
type JsonSchema,
|
||||
type StructuredGenerationAttempt,
|
||||
type StructuredGenerationProvider,
|
||||
type StructuredAgentGenerationOptions,
|
||||
type StructuredAgentGenerationWithFallbackOptions,
|
||||
type StructuredAgentResponseOptions,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
|
||||
// WebSocket message types for CLI streaming
|
||||
export type {
|
||||
|
||||
@@ -81,8 +81,10 @@ import {
|
||||
} from "./agent/timeline-append.js";
|
||||
import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponse,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import type {
|
||||
AgentPermissionResponse,
|
||||
@@ -166,12 +168,6 @@ const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
|
||||
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024;
|
||||
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048;
|
||||
|
||||
/**
|
||||
* Default model used for auto-generating commit messages and PR descriptions.
|
||||
* Uses Claude Haiku for speed and cost efficiency.
|
||||
*/
|
||||
const AUTO_GEN_MODEL = "haiku";
|
||||
|
||||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
@@ -297,6 +293,7 @@ type FetchAgentsRequestMessage = Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "fetch_agents_request" }
|
||||
>;
|
||||
type FetchAgentsRequestFilter = NonNullable<FetchAgentsRequestMessage["filter"]>;
|
||||
type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage["sort"]>[number];
|
||||
type FetchAgentsResponsePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
@@ -304,6 +301,17 @@ type FetchAgentsResponsePayload = Extract<
|
||||
>["payload"];
|
||||
type FetchAgentsResponseEntry = FetchAgentsResponsePayload["entries"][number];
|
||||
type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
|
||||
type AgentUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent_update" }
|
||||
>["payload"];
|
||||
type AgentUpdatesFilter = FetchAgentsRequestFilter;
|
||||
type AgentUpdatesSubscriptionState = {
|
||||
subscriptionId: string;
|
||||
filter?: AgentUpdatesFilter;
|
||||
isBootstrapping: boolean;
|
||||
pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>;
|
||||
};
|
||||
type FetchAgentsCursor = {
|
||||
sort: FetchAgentsRequestSort[];
|
||||
values: Record<string, string | number | null>;
|
||||
@@ -555,12 +563,7 @@ export class Session {
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>;
|
||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||
private agentUpdatesSubscription:
|
||||
| {
|
||||
subscriptionId: string;
|
||||
filter?: { labels?: Record<string, string>; agentId?: string };
|
||||
}
|
||||
| null = null;
|
||||
private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null;
|
||||
private clientActivity: {
|
||||
deviceType: "web" | "mobile";
|
||||
focusedAgentId: string | null;
|
||||
@@ -1085,19 +1088,105 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private matchesAgentFilter(
|
||||
agent: AgentSnapshotPayload,
|
||||
filter?: { labels?: Record<string, string>; agentId?: string }
|
||||
): boolean {
|
||||
if (filter?.agentId && agent.id !== filter.agentId) {
|
||||
private matchesAgentFilter(options: {
|
||||
agent: AgentSnapshotPayload;
|
||||
project: ProjectPlacementPayload;
|
||||
filter?: AgentUpdatesFilter;
|
||||
}): boolean {
|
||||
const { agent, project, filter } = options;
|
||||
|
||||
if (filter?.labels) {
|
||||
const matchesLabels = Object.entries(filter.labels).every(
|
||||
([key, value]) => agent.labels[key] === value
|
||||
);
|
||||
if (!matchesLabels) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const includeArchived = filter?.includeArchived ?? false;
|
||||
if (!includeArchived && agent.archivedAt) {
|
||||
return false;
|
||||
}
|
||||
if (!filter?.labels) {
|
||||
return true;
|
||||
|
||||
if (filter?.statuses && filter.statuses.length > 0) {
|
||||
const statuses = new Set(filter.statuses);
|
||||
if (!statuses.has(agent.status)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof filter?.requiresAttention === "boolean") {
|
||||
const requiresAttention = agent.requiresAttention ?? false;
|
||||
if (requiresAttention !== filter.requiresAttention) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filter?.projectKeys && filter.projectKeys.length > 0) {
|
||||
const projectKeys = new Set(
|
||||
filter.projectKeys.filter((item) => item.trim().length > 0)
|
||||
);
|
||||
if (projectKeys.size > 0 && !projectKeys.has(project.projectKey)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getAgentUpdateTargetId(update: AgentUpdatePayload): string {
|
||||
return update.kind === "remove" ? update.agentId : update.agent.id;
|
||||
}
|
||||
|
||||
private bufferOrEmitAgentUpdate(
|
||||
subscription: AgentUpdatesSubscriptionState,
|
||||
payload: AgentUpdatePayload
|
||||
): void {
|
||||
if (subscription.isBootstrapping) {
|
||||
subscription.pendingUpdatesByAgentId.set(
|
||||
this.getAgentUpdateTargetId(payload),
|
||||
payload
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "agent_update",
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
private flushBootstrappedAgentUpdates(options?: {
|
||||
snapshotUpdatedAtByAgentId?: Map<string, number>;
|
||||
}): void {
|
||||
const subscription = this.agentUpdatesSubscription;
|
||||
if (!subscription || !subscription.isBootstrapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
subscription.isBootstrapping = false;
|
||||
const pending = Array.from(subscription.pendingUpdatesByAgentId.values());
|
||||
subscription.pendingUpdatesByAgentId.clear();
|
||||
|
||||
for (const payload of pending) {
|
||||
if (payload.kind === "upsert") {
|
||||
const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get(
|
||||
payload.agent.id
|
||||
);
|
||||
if (typeof snapshotUpdatedAt === "number") {
|
||||
const updateUpdatedAt = Date.parse(payload.agent.updatedAt);
|
||||
if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt <= snapshotUpdatedAt) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "agent_update",
|
||||
payload,
|
||||
});
|
||||
}
|
||||
return Object.entries(filter.labels).every(
|
||||
([key, value]) => agent.labels[key] === value
|
||||
);
|
||||
}
|
||||
|
||||
private buildFallbackProjectCheckout(cwd: string): ProjectCheckoutLitePayload {
|
||||
@@ -1163,51 +1252,31 @@ export class Session {
|
||||
}
|
||||
|
||||
const payload = await this.buildAgentPayload(agent);
|
||||
const matches = this.matchesAgentFilter(payload, subscription.filter);
|
||||
const project = await this.buildProjectPlacement(payload.cwd);
|
||||
const matches = this.matchesAgentFilter({
|
||||
agent: payload,
|
||||
project,
|
||||
filter: subscription.filter,
|
||||
});
|
||||
|
||||
if (matches) {
|
||||
const project = await this.buildProjectPlacement(payload.cwd);
|
||||
this.emit({
|
||||
type: "agent_update",
|
||||
payload: { kind: "upsert", agent: payload, project },
|
||||
this.bufferOrEmitAgentUpdate(subscription, {
|
||||
kind: "upsert",
|
||||
agent: payload,
|
||||
project,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "agent_update",
|
||||
payload: { kind: "remove", agentId: payload.id },
|
||||
this.bufferOrEmitAgentUpdate(subscription, {
|
||||
kind: "remove",
|
||||
agentId: payload.id,
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, "Failed to emit agent update");
|
||||
}
|
||||
}
|
||||
|
||||
private async emitCurrentAgentUpdatesForSubscription(): Promise<void> {
|
||||
const subscription = this.agentUpdatesSubscription;
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const agents = await this.listAgentPayloads({
|
||||
labels: subscription.filter?.labels,
|
||||
});
|
||||
for (const agent of agents) {
|
||||
const project = await this.buildProjectPlacement(agent.cwd);
|
||||
this.emit({
|
||||
type: "agent_update",
|
||||
payload: { kind: "upsert", agent, project },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error },
|
||||
"Failed to emit current agent updates for subscription bootstrap"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for processing session messages
|
||||
*/
|
||||
@@ -1234,22 +1303,6 @@ export class Session {
|
||||
await this.handleFetchAgent(msg.agentId, msg.requestId);
|
||||
break;
|
||||
|
||||
case "subscribe_agent_updates":
|
||||
this.agentUpdatesSubscription = {
|
||||
subscriptionId: msg.subscriptionId,
|
||||
filter: msg.filter,
|
||||
};
|
||||
await this.emitCurrentAgentUpdatesForSubscription();
|
||||
break;
|
||||
|
||||
case "unsubscribe_agent_updates":
|
||||
if (
|
||||
this.agentUpdatesSubscription?.subscriptionId === msg.subscriptionId
|
||||
) {
|
||||
this.agentUpdatesSubscription = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case "delete_agent_request":
|
||||
await this.handleDeleteAgentRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
@@ -1706,9 +1759,9 @@ export class Session {
|
||||
});
|
||||
|
||||
if (this.agentUpdatesSubscription) {
|
||||
this.emit({
|
||||
type: "agent_update",
|
||||
payload: { kind: "remove", agentId },
|
||||
this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, {
|
||||
kind: "remove",
|
||||
agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3042,23 +3095,25 @@ export class Session {
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n");
|
||||
try {
|
||||
const result = await generateStructuredAgentResponse({
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
agentConfig: {
|
||||
provider: "claude",
|
||||
model: AUTO_GEN_MODEL,
|
||||
cwd,
|
||||
title: "Commit generator",
|
||||
internal: true,
|
||||
},
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "CommitMessage",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
agentConfigOverrides: {
|
||||
title: "Commit generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
return result.message;
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
return "Update files";
|
||||
}
|
||||
throw error;
|
||||
@@ -3107,22 +3162,24 @@ export class Session {
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n");
|
||||
try {
|
||||
return await generateStructuredAgentResponse({
|
||||
return await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
agentConfig: {
|
||||
provider: "claude",
|
||||
model: AUTO_GEN_MODEL,
|
||||
cwd,
|
||||
title: "PR generator",
|
||||
internal: true,
|
||||
},
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "PullRequest",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
agentConfigOverrides: {
|
||||
title: "PR generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
return {
|
||||
title: "Update changes",
|
||||
body: "Automated PR generated by Paseo.",
|
||||
@@ -5097,28 +5154,11 @@ export class Session {
|
||||
}> {
|
||||
const filter = request.filter;
|
||||
const sort = this.normalizeFetchAgentsSort(request.sort);
|
||||
const includeArchived = filter?.includeArchived ?? false;
|
||||
|
||||
let agents = await this.listAgentPayloads({
|
||||
const agents = await this.listAgentPayloads({
|
||||
labels: filter?.labels,
|
||||
});
|
||||
|
||||
if (!includeArchived) {
|
||||
agents = agents.filter((agent) => !agent.archivedAt);
|
||||
}
|
||||
|
||||
if (filter?.statuses && filter.statuses.length > 0) {
|
||||
const statuses = new Set(filter.statuses);
|
||||
agents = agents.filter((agent) => statuses.has(agent.status));
|
||||
}
|
||||
|
||||
if (typeof filter?.requiresAttention === "boolean") {
|
||||
agents = agents.filter(
|
||||
(agent) =>
|
||||
(agent.requiresAttention ?? false) === filter.requiresAttention
|
||||
);
|
||||
}
|
||||
|
||||
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload>>();
|
||||
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload> => {
|
||||
const existing = placementByCwd.get(cwd);
|
||||
@@ -5136,11 +5176,13 @@ export class Session {
|
||||
project: await getPlacement(agent.cwd),
|
||||
}))
|
||||
);
|
||||
|
||||
if (filter?.projectKeys && filter.projectKeys.length > 0) {
|
||||
const projectKeys = new Set(filter.projectKeys.filter((item) => item.trim().length > 0));
|
||||
entries = entries.filter((entry) => projectKeys.has(entry.project.projectKey));
|
||||
}
|
||||
entries = entries.filter((entry) =>
|
||||
this.matchesAgentFilter({
|
||||
agent: entry.agent,
|
||||
project: entry.project,
|
||||
filter,
|
||||
})
|
||||
);
|
||||
|
||||
entries.sort((left, right) =>
|
||||
this.compareFetchAgentsEntries(left, right, sort)
|
||||
@@ -5178,16 +5220,55 @@ export class Session {
|
||||
private async handleFetchAgents(
|
||||
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>
|
||||
): Promise<void> {
|
||||
const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim();
|
||||
const subscriptionId =
|
||||
request.subscribe
|
||||
? requestedSubscriptionId && requestedSubscriptionId.length > 0
|
||||
? requestedSubscriptionId
|
||||
: uuidv4()
|
||||
: null;
|
||||
|
||||
try {
|
||||
if (subscriptionId) {
|
||||
this.agentUpdatesSubscription = {
|
||||
subscriptionId,
|
||||
filter: request.filter,
|
||||
isBootstrapping: true,
|
||||
pendingUpdatesByAgentId: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await this.listFetchAgentsEntries(request);
|
||||
const snapshotUpdatedAtByAgentId = new Map<string, number>();
|
||||
for (const entry of payload.entries) {
|
||||
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
|
||||
if (!Number.isNaN(parsedUpdatedAt)) {
|
||||
snapshotUpdatedAtByAgentId.set(entry.agent.id, parsedUpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "fetch_agents_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
...(subscriptionId ? { subscriptionId } : {}),
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
subscriptionId &&
|
||||
this.agentUpdatesSubscription?.subscriptionId === subscriptionId
|
||||
) {
|
||||
this.flushBootstrappedAgentUpdates({ snapshotUpdatedAtByAgentId });
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
subscriptionId &&
|
||||
this.agentUpdatesSubscription?.subscriptionId === subscriptionId
|
||||
) {
|
||||
this.agentUpdatesSubscription = null;
|
||||
}
|
||||
const code =
|
||||
error instanceof SessionRequestError ? error.code : "fetch_agents_failed";
|
||||
const message =
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function createDaemonTestContext(
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
});
|
||||
await client.connect();
|
||||
client.subscribeAgentUpdates({ subscriptionId: "test" });
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
|
||||
return {
|
||||
daemon,
|
||||
|
||||
@@ -405,26 +405,12 @@ export const AudioPlayedMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const RequestAgentListMessageSchema = z.object({
|
||||
type: z.literal("request_agent_list"),
|
||||
requestId: z.string(),
|
||||
filter: z.object({
|
||||
labels: z.record(z.string()).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const SubscribeAgentUpdatesMessageSchema = z.object({
|
||||
type: z.literal("subscribe_agent_updates"),
|
||||
subscriptionId: z.string(),
|
||||
filter: z.object({
|
||||
labels: z.record(z.string()).optional(),
|
||||
agentId: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const UnsubscribeAgentUpdatesMessageSchema = z.object({
|
||||
type: z.literal("unsubscribe_agent_updates"),
|
||||
subscriptionId: z.string(),
|
||||
const AgentDirectoryFilterSchema = z.object({
|
||||
labels: z.record(z.string()).optional(),
|
||||
projectKeys: z.array(z.string()).optional(),
|
||||
statuses: z.array(AgentStatusSchema).optional(),
|
||||
includeArchived: z.boolean().optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const DeleteAgentRequestMessageSchema = z.object({
|
||||
@@ -472,15 +458,7 @@ export const SendAgentMessageSchema = z.object({
|
||||
export const FetchAgentsRequestMessageSchema = z.object({
|
||||
type: z.literal("fetch_agents_request"),
|
||||
requestId: z.string(),
|
||||
filter: z
|
||||
.object({
|
||||
labels: z.record(z.string()).optional(),
|
||||
projectKeys: z.array(z.string()).optional(),
|
||||
statuses: z.array(AgentStatusSchema).optional(),
|
||||
includeArchived: z.boolean().optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
filter: AgentDirectoryFilterSchema.optional(),
|
||||
sort: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -495,6 +473,11 @@ export const FetchAgentsRequestMessageSchema = z.object({
|
||||
cursor: z.string().min(1).optional(),
|
||||
})
|
||||
.optional(),
|
||||
subscribe: z
|
||||
.object({
|
||||
subscriptionId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const FetchAgentRequestMessageSchema = z.object({
|
||||
@@ -1045,8 +1028,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AudioPlayedMessageSchema,
|
||||
FetchAgentsRequestMessageSchema,
|
||||
FetchAgentRequestMessageSchema,
|
||||
SubscribeAgentUpdatesMessageSchema,
|
||||
UnsubscribeAgentUpdatesMessageSchema,
|
||||
DeleteAgentRequestMessageSchema,
|
||||
ArchiveAgentRequestMessageSchema,
|
||||
UpdateAgentRequestMessageSchema,
|
||||
@@ -1443,6 +1424,7 @@ export const FetchAgentsResponseMessageSchema = z.object({
|
||||
type: z.literal("fetch_agents_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
subscriptionId: z.string().nullable().optional(),
|
||||
entries: z.array(
|
||||
z.object({
|
||||
agent: AgentSnapshotPayloadSchema,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
||||
import {
|
||||
createTerminal,
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
|
||||
type TerminalSession,
|
||||
} from "./terminal.js";
|
||||
import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
// Extract text from a single row
|
||||
function getRowText(state: ReturnType<TerminalSession["getState"]>, rowIndex: number): string {
|
||||
@@ -40,12 +47,19 @@ async function waitForLines(
|
||||
|
||||
describe("Terminal", () => {
|
||||
const sessions: TerminalSession[] = [];
|
||||
const temporaryDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions) {
|
||||
session.kill();
|
||||
}
|
||||
sessions.length = 0;
|
||||
while (temporaryDirs.length > 0) {
|
||||
const dir = temporaryDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function trackSession(session: TerminalSession): TerminalSession {
|
||||
@@ -54,6 +68,24 @@ describe("Terminal", () => {
|
||||
}
|
||||
|
||||
describe("createTerminal", () => {
|
||||
it("ensures darwin prebuild spawn-helper is executable", () => {
|
||||
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-"));
|
||||
temporaryDirs.push(packageRoot);
|
||||
const prebuildDir = join(packageRoot, "prebuilds", `darwin-${process.arch}`);
|
||||
mkdirSync(prebuildDir, { recursive: true });
|
||||
const helperPath = join(prebuildDir, "spawn-helper");
|
||||
writeFileSync(helperPath, "#!/bin/sh\necho helper\n");
|
||||
chmodSync(helperPath, 0o644);
|
||||
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform({
|
||||
packageRoot,
|
||||
platform: "darwin",
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(statSync(helperPath).mode & 0o111).toBe(0o111);
|
||||
});
|
||||
|
||||
it("creates a terminal session with an id, name, and cwd", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import * as pty from "node-pty";
|
||||
import xterm, { type Terminal as TerminalType } from "@xterm/headless";
|
||||
import { randomUUID } from "crypto";
|
||||
import { chmodSync, existsSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
const require = createRequire(import.meta.url);
|
||||
let nodePtySpawnHelperChecked = false;
|
||||
|
||||
export interface Cell {
|
||||
char: string;
|
||||
@@ -83,6 +88,73 @@ export interface CreateTerminalOptions {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
type EnsureNodePtySpawnHelperExecutableOptions = {
|
||||
packageRoot?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
arch?: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
function resolveNodePtyPackageRoot(): string | null {
|
||||
try {
|
||||
const packageJsonPath = require.resolve("node-pty/package.json");
|
||||
return dirname(packageJsonPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureExecutableBit(path: string): void {
|
||||
if (!existsSync(path)) {
|
||||
return;
|
||||
}
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile()) {
|
||||
return;
|
||||
}
|
||||
// node-pty 1.1.0 shipped darwin prebuild spawn-helper without execute bit.
|
||||
if ((stat.mode & 0o111) === 0o111) {
|
||||
return;
|
||||
}
|
||||
chmodSync(path, stat.mode | 0o111);
|
||||
}
|
||||
|
||||
export function ensureNodePtySpawnHelperExecutableForCurrentPlatform(
|
||||
options: EnsureNodePtySpawnHelperExecutableOptions = {}
|
||||
): void {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform !== "darwin") {
|
||||
return;
|
||||
}
|
||||
if (nodePtySpawnHelperChecked && !options.force) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageRoot = options.packageRoot ?? resolveNodePtyPackageRoot();
|
||||
if (!packageRoot) {
|
||||
return;
|
||||
}
|
||||
const arch = options.arch ?? process.arch;
|
||||
|
||||
const candidates = [
|
||||
join(packageRoot, "build", "Release", "spawn-helper"),
|
||||
join(packageRoot, "build", "Debug", "spawn-helper"),
|
||||
join(packageRoot, "prebuilds", `darwin-${arch}`, "spawn-helper"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
ensureExecutableBit(candidate);
|
||||
} catch {
|
||||
// best-effort hardening only
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.force) {
|
||||
nodePtySpawnHelperChecked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCell(terminal: TerminalType, row: number, col: number): Cell {
|
||||
const buffer = terminal.buffer.active;
|
||||
const line = buffer.getLine(row);
|
||||
@@ -212,6 +284,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
|
||||
|
||||
// Create PTY
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user