Compare commits

...

5 Commits

Author SHA1 Message Date
Mohamed Boudra
bbf3d0f9cc Keep plan approval focused on the latest proposal (#2534)
* fix(server): replace stale plan approvals

Synthetic plan approvals accumulated across planning turns because each proposal used a new permission ID. Dismiss the current proposal only after a follow-up prompt is accepted, and enforce one pending proposal when a newer plan arrives.

* fix(server): close plan approval submission race

Deactivate the existing proposal before starting a revision so it cannot be implemented concurrently. Restore it when submission fails, while preserving any newer approval emitted during the request.

* fix(server): deactivate plans before prompt setup

Start the plan-dismissal transaction before any asynchronous prompt setup so a stale proposal cannot be implemented from another client while the revision is preparing.

* fix(server): make prompt dismissal final

Treat prompt submission as the dismissal event instead of compensating after failures. This avoids resurrecting stale plans across ambiguous provider outcomes and manager failure cleanup.
2026-07-28 17:10:32 +02:00
Mohamed Boudra
963d4f9240 Configure agent thinking from the CLI (#2533)
* feat(cli): update agent thinking from the CLI

* fix(cli): harden agent thinking updates

* feat(cli): configure thinking for schedules

* fix(cli): report applied thinking updates

* fix(cli): report current agent thinking state
2026-07-28 22:30:50 +08:00
nllptrx
e241e02afb feat(app): dismiss the chat keyboard on a fast upward flick (#2417)
* feat(app): dismiss the chat keyboard on a fast upward flick

Scrolling the history to read earlier messages left the keyboard up, so the
visible transcript stayed cramped and the keyboard had to be closed by hand
first — two steps for what should be one.

React Native's keyboardDismissMode cannot express the wanted behaviour:
"on-drag" fires on the first pixel and kills the keyboard on any peek-scroll,
and "interactive" is broken on inverted lists. So the gesture is measured
here: samples are taken from the scroll events and, at release, the speed over
the drag's final stretch decides. Measuring at release rather than averaging
the whole drag is what keeps a fast but controlled read-scroll from counting
as a flick, since such a gesture decelerates before the finger lifts.

Timestamps and offsets come from the events, never from a clock: with a busy
JS thread the callbacks arrive in a burst long after the gesture, and
wall-clock spacing then reads a calm scroll as a flick. The release offset
comes from the end-drag event for the same reason — the last onScroll can be
stale by the time a short flick lands.

Android needs both the blur and the dismiss. Dismissing alone leaves the
input focused and the keyboard inset applied, so the layout stays shifted
with an empty gap where the keyboard was; blurring alone releases focus but
leaves the IME on screen.

Verified on an Android device with a Release build: a slow drag and a
0.6 dp/ms scroll keep the keyboard, a 2.9 dp/ms flick dismisses it and the
composer settles back with no leftover gap. iOS was exercised by hand on
device only, without automated coverage of the gesture itself.

* refactor(app): isolate keyboard flick dismissal

* fix(app): isolate keyboard shift context

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 22:29:33 +08:00
Mohamed Boudra
fdee3236f7 Speed up server CI tests (#2537)
* perf(ci): reduce server test latency

Server test files use isolated resources, so they do not require suite-wide serialization.

* fix(ci): scope server test parallelism to unit suite

Keep real-provider and local-resource suites serialized because they may share user configuration and account limits.

* fix(terminal): isolate zsh runtimes by process

Prevent concurrent daemon and test processes from deleting or replacing shell integration files used by another process.

* fix(ci): preserve required matrix check names

GitHub evaluates job conditions before expanding a matrix. Expand active matrices first and gate their expensive steps so required check contexts are always reported. Allow superseded runs to cancel while retaining fail-open path detection.
2026-07-28 21:51:12 +08:00
stonegray
fd7061a8b2 Fix AppImage launches from Linux desktops (#2439) 2026-07-28 13:59:26 +02:00
30 changed files with 1213 additions and 150 deletions

View File

@@ -115,6 +115,9 @@ jobs:
- 'packages/relay/**'
- 'packages/server/**'
- name: Validate CI workflow
run: node --test scripts/ci-workflow.test.mjs
format:
runs-on: ubuntu-latest
env:
@@ -138,7 +141,7 @@ jobs:
lint:
needs: changes
if: >-
${{ always() &&
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
@@ -168,7 +171,7 @@ jobs:
typecheck:
needs: changes
if: >-
${{ always() &&
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
@@ -199,11 +202,7 @@ jobs:
server-tests:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false') }}
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -212,28 +211,43 @@ jobs:
name: server-tests (${{ matrix.os }})
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false' }}
steps:
- name: Skip unaffected server tests
if: env.RUN_TESTS != 'true'
run: echo "No server changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
with:
fetch-depth: 0
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
if: env.RUN_TESTS == 'true'
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:server-deps
- name: Run server tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -242,11 +256,7 @@ jobs:
desktop-tests:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false') }}
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -255,39 +265,53 @@ jobs:
timeout-minutes: 30
permissions:
contents: read
env:
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false' }}
steps:
- name: Skip unaffected desktop tests
if: env.RUN_TESTS != 'true'
run: echo "No desktop changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Run desktop tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/desktop
- name: Build app dependencies for desktop E2E
if: matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: npm run build:app-deps
- name: Install virtual display
if: matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Run real Electron browser tab bridge E2E
if: matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
env:
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
- name: Upload browser tab bridge diagnostics
uses: actions/upload-artifact@v4
if: failure() && matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
with:
name: browser-tab-bridge-e2e
path: ${{ runner.temp }}/browser-tab-bridge-e2e
@@ -296,7 +320,7 @@ jobs:
- name: Build and smoke unpacked desktop app
if: >-
matrix.os == 'ubuntu-latest' &&
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
@@ -308,7 +332,7 @@ jobs:
- name: Upload packaged smoke diagnostics
if: >-
failure() && matrix.os == 'ubuntu-latest' &&
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
@@ -322,7 +346,7 @@ jobs:
app-tests:
needs: changes
if: >-
${{ always() &&
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.app != 'false') }}
@@ -352,7 +376,7 @@ jobs:
sdk-tests:
needs: changes
if: >-
${{ always() &&
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.sdk != 'false') }}
@@ -383,11 +407,7 @@ jobs:
playwright:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false') }}
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -401,43 +421,57 @@ jobs:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false' }}
steps:
- name: Skip unaffected Playwright tests
if: env.RUN_TESTS != 'true'
run: echo "No Playwright changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
if: env.RUN_TESTS == 'true'
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:app-deps
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: ${{ !matrix.desktop }}
if: env.RUN_TESTS == 'true' && !matrix.desktop
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
if: ${{ !matrix.desktop }}
if: env.RUN_TESTS == 'true' && !matrix.desktop
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests
if: ${{ matrix.desktop }}
if: env.RUN_TESTS == 'true' && matrix.desktop
run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
if: env.RUN_TESTS == 'true' && failure()
with:
name: playwright-results-${{ matrix.shard }}
path: |
@@ -448,7 +482,7 @@ jobs:
relay-tests:
needs: changes
if: >-
${{ always() &&
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.relay != 'false') }}
@@ -474,11 +508,7 @@ jobs:
cli-tests:
needs: changes
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false') }}
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -487,24 +517,38 @@ jobs:
name: cli-tests (shard ${{ matrix.shard }}/3)
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false' }}
steps:
- name: Skip unaffected CLI tests
if: env.RUN_TESTS != 'true'
run: echo "No CLI changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"

View File

@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
beginDrag,
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
recordScroll,
releaseDrag,
type ScrollKeyboardDismissEvent,
type ScrollKeyboardDismissGesture,
} from "./model";
interface Point {
ts: number;
y: number;
nativeTs?: number | null;
}
function event(point: Point): ScrollKeyboardDismissEvent {
return {
timeStamp: point.ts,
nativeEvent: {
contentOffset: { y: point.y },
...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }),
},
};
}
function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture {
return points.reduce(
(gesture, point) => recordScroll(gesture, event(point)),
beginDrag(event(start)),
);
}
function shouldDismiss(start: Point, points: Point[], release: Point): boolean {
return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss;
}
describe("scroll keyboard dismissal", () => {
it("keeps the keyboard up for a slow read-scroll", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 8 },
{ ts: 1080, y: 16 },
{ ts: 1120, y: 24 },
],
{ ts: 1160, y: 32 },
),
).toBe(false);
});
it("dismisses for an upward flick", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
true,
);
});
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
false,
);
});
it("keeps a fast-but-decelerating drag below the release threshold", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1100, y: 500 },
{ ts: 1200, y: 590 },
{ ts: 1270, y: 598 },
],
{ ts: 1300, y: 600 },
),
).toBe(false);
});
it("uses the whole drag when the gesture is too short to sample", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
});
it("steps back a sample when release lands immediately after one", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 120 },
{ ts: 1075, y: 225 },
],
{ ts: 1080, y: 240 },
),
).toBe(true);
});
it("keeps the keyboard up when release has no measurable time span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
});
it("ignores scroll events that arrive before the minimum sample span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
true,
);
});
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
expect(
shouldDismiss(
{ ts: 5000, nativeTs: 1000, y: 0 },
[
{ ts: 5001, nativeTs: 1200, y: 40 },
{ ts: 5002, nativeTs: 1400, y: 80 },
],
{ ts: 5003, nativeTs: 1600, y: 120 },
),
).toBe(false);
});
it("falls back to synthetic event time when native time is absent", () => {
expect(
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
ts: 1080,
nativeTs: null,
y: 240,
}),
).toBe(true);
});
it("ignores scroll and release events without a matching drag", () => {
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
});
it("returns to idle after release", () => {
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
});
});

View File

@@ -0,0 +1,124 @@
/**
* Pure state machine for the chat history's flick-to-dismiss gesture.
*
* Native keyboardDismissMode cannot express this interaction: "on-drag"
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
* inverted list. We therefore classify the list's existing scroll gesture at
* release instead of introducing a competing pan recognizer.
*/
const DISMISS_VELOCITY_POINTS_PER_MS = 1.5;
const RELEASE_SAMPLE_MIN_MS = 30;
/**
* The subset of a native scroll event used by the classifier. React Native's
* type omits `nativeEvent.timestamp`, although iOS and Android both send it.
*/
export interface ScrollKeyboardDismissEvent {
timeStamp: number;
nativeEvent: {
contentOffset: { y: number };
timestamp?: number;
};
}
interface DragSamples {
startTs: number;
startY: number;
sampleTs: number;
sampleY: number;
previousSampleTs: number;
previousSampleY: number;
}
export type ScrollKeyboardDismissGesture =
| { phase: "idle" }
| { phase: "dragging"; samples: DragSamples };
export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({
phase: "idle",
});
function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number {
const nativeTimestamp = event.nativeEvent.timestamp;
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
return nativeTimestamp;
}
return event.timeStamp;
}
export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture {
const timestamp = resolveEventTimeMs(event);
const offsetY = event.nativeEvent.contentOffset.y;
return {
phase: "dragging",
samples: {
startTs: timestamp,
startY: offsetY,
sampleTs: timestamp,
sampleY: offsetY,
previousSampleTs: 0,
previousSampleY: 0,
},
};
}
export function recordScroll(
gesture: ScrollKeyboardDismissGesture,
event: ScrollKeyboardDismissEvent,
): ScrollKeyboardDismissGesture {
if (gesture.phase === "idle") {
return gesture;
}
const timestamp = resolveEventTimeMs(event);
if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) {
return gesture;
}
const { samples } = gesture;
return {
phase: "dragging",
samples: {
startTs: samples.startTs,
startY: samples.startY,
sampleTs: timestamp,
sampleY: event.nativeEvent.contentOffset.y,
previousSampleTs: samples.sampleTs,
previousSampleY: samples.sampleY,
},
};
}
export function releaseDrag(
gesture: ScrollKeyboardDismissGesture,
event: ScrollKeyboardDismissEvent,
): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } {
if (gesture.phase === "idle") {
return { gesture, shouldDismiss: false };
}
const releaseTs = resolveEventTimeMs(event);
const releaseY = event.nativeEvent.contentOffset.y;
const { samples } = gesture;
// The release event carries the gesture's true endpoint. The final onScroll
// event can still be stale when a short flick lands.
let spanStartTs = samples.startTs;
let spanStartY = samples.startY;
if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) {
spanStartTs = samples.sampleTs;
spanStartY = samples.sampleY;
} else if (samples.previousSampleTs > 0) {
spanStartTs = samples.previousSampleTs;
spanStartY = samples.previousSampleY;
}
const spanDurationMs = releaseTs - spanStartTs;
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
return {
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
};
}

View File

@@ -0,0 +1,57 @@
import { useRef } from "react";
import {
Keyboard,
TextInput,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
beginDrag,
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
recordScroll,
releaseDrag,
} from "./model";
type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
/**
* Owns the chat history's flick-to-dismiss behavior. The native stream only
* forwards the FlatList scroll lifecycle; removing this hook and those three
* calls removes the feature completely.
*/
export function useScrollKeyboardDismiss() {
const { shift } = useKeyboardShift();
const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => {
gestureRef.current = beginDrag(event);
});
const onScroll = useStableEvent((event: ScrollEvent) => {
gestureRef.current = recordScroll(gestureRef.current, event);
});
const onScrollEndDrag = useStableEvent((event: ScrollEvent) => {
const release = releaseDrag(gestureRef.current, event);
gestureRef.current = release.gesture;
// `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a
// second calculation on JS, this prevents a hardware keyboard's focused
// composer from being blurred when no software keyboard occupies space.
if (!release.shouldDismiss || shift.value <= 0) {
return;
}
// Keep blur and dismiss paired: this exact sequence was validated on a
// physical Android device to clear both input focus and the IME inset.
const focusedInput = TextInput.State.currentlyFocusedInput();
if (focusedInput) {
TextInput.State.blurTextInput(focusedInput);
}
Keyboard.dismiss();
});
return { onScroll, onScrollBeginDrag, onScrollEndDrag };
}

View File

@@ -24,6 +24,7 @@ import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import {
createStreamStrategy,
@@ -52,7 +53,6 @@ const historyStartSlotStyle: ViewStyle = {
paddingTop: 4,
paddingBottom: 8,
};
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
@@ -110,6 +110,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
@@ -335,6 +336,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
scrollOffsetYRef.current = contentOffset.y;
scrollKeyboardDismiss.onScroll(event);
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportWidth: Math.max(0, layoutMeasurement.width),
@@ -365,7 +368,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
const handleScrollBeginDrag = useStableEvent(() => {
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
@@ -373,6 +376,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
bottomAnchorController.beginUserScroll();
evaluateHistoryStart();
});
@@ -381,6 +385,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
scrollKeyboardDismiss.onScrollEndDrag(event);
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;

View File

@@ -8,7 +8,7 @@ import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";

View File

@@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
import type { SharedValue } from "react-native-reanimated";
export interface KeyboardShiftContextValue {
shift: SharedValue<number>;
bottomInset: SharedValue<number>;
}
export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
/** Read the app-wide keyboard inset without loading its native provider implementation. */
export function useKeyboardShift(): KeyboardShiftContextValue {
const context = useContext(KeyboardShiftContext);
if (!context) {
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
}
return context;
}

View File

@@ -1,11 +1,4 @@
import {
createContext,
createElement,
useContext,
useEffect,
useMemo,
type ReactNode,
} from "react";
import { createElement, useEffect, useMemo, type ReactNode } from "react";
import { Platform } from "react-native";
import type { ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -23,16 +16,10 @@ import {
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
resolveKeyboardShift,
} from "@/hooks/keyboard-shift-policy";
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
type KeyboardShiftMode = "translate" | "padding";
interface KeyboardShiftContextValue {
shift: SharedValue<number>;
bottomInset: SharedValue<number>;
}
const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets();
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
@@ -78,14 +65,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
return createElement(KeyboardShiftContext.Provider, { value }, children);
}
export function useKeyboardShift(): KeyboardShiftContextValue {
const context = useContext(KeyboardShiftContext);
if (!context) {
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
}
return context;
}
export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): {
shift: SharedValue<number>;
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;

View File

@@ -35,6 +35,19 @@ describe("canonical CLI surface", () => {
expect(run?.helpInformation()).not.toContain("--detach");
});
it("offers thinking configuration when running, updating, and scheduling agents", () => {
const cli = createCli();
const run = cli.commands.find((command) => command.name() === "run");
const agent = cli.commands.find((command) => command.name() === "agent");
const update = agent?.commands.find((command) => command.name() === "update");
const schedule = cli.commands.find((command) => command.name() === "schedule");
const scheduleCreate = schedule?.commands.find((command) => command.name() === "create");
expect(run?.helpInformation()).toContain("--thinking <id>");
expect(update?.helpInformation()).toContain("--thinking <id>");
expect(scheduleCreate?.helpInformation()).toContain("--thinking <id>");
});
it("offers opening an existing agent in the desktop app", () => {
const agent = createCli().commands.find((command) => command.name() === "agent");
const open = agent?.commands.find((command) => command.name() === "open");

View File

@@ -92,9 +92,10 @@ export function createAgentCommand(): Command {
addJsonAndDaemonHostOptions(
agent
.command("update")
.description("Update an agent's metadata")
.description("Update an agent's settings or metadata")
.argument("<id>", "Agent ID (or prefix)")
.option("--name <name>", "Update the agent's display name")
.option("--thinking <id>", "Update the agent's thinking option ID")
.option(
"--label <label>",
"Add/set label(s) on the agent (can be used multiple times or comma-separated)",

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import {
applyAgentChanges,
toAgentUpdateResult,
type AgentMetadataChanges,
type AgentUpdateClient,
} from "./update.js";
class RecordingAgentUpdateClient implements AgentUpdateClient {
readonly metadataUpdates: Array<{
agentId: string;
updates: AgentMetadataChanges;
}> = [];
readonly thinkingUpdates: Array<{ agentId: string; thinkingOptionId: string }> = [];
thinkingNotice: AgentProviderNotice | null = null;
constructor(private readonly supportsThinkingUpdate = true) {}
getLastServerInfoMessage() {
return { features: { agentThinkingUpdate: this.supportsThinkingUpdate } };
}
async updateAgent(agentId: string, updates: AgentMetadataChanges): Promise<void> {
this.metadataUpdates.push({ agentId, updates });
}
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string,
): Promise<AgentProviderNotice | null> {
this.thinkingUpdates.push({ agentId, thinkingOptionId });
return this.thinkingNotice;
}
}
describe("applyAgentChanges", () => {
it("updates an agent's thinking without issuing an empty metadata update", async () => {
const client = new RecordingAgentUpdateClient();
const result = await applyAgentChanges(client, "agent-1", {
type: "thinking",
thinkingOptionId: "high",
});
expect(client.metadataUpdates).toEqual([]);
expect(client.thinkingUpdates).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
expect(result).toEqual({
notice: null,
});
});
it("requires a daemon that advertises thinking updates", async () => {
const client = new RecordingAgentUpdateClient(false);
await expect(
applyAgentChanges(client, "agent-1", { type: "thinking", thinkingOptionId: "high" }),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use agent thinking updates.",
});
expect(client.metadataUpdates).toEqual([]);
expect(client.thinkingUpdates).toEqual([]);
});
it("returns the provider notice from a thinking update", async () => {
const client = new RecordingAgentUpdateClient();
client.thinkingNotice = {
type: "warning",
message: "Thinking changes apply to the next turn.",
};
const notice = await applyAgentChanges(client, "agent-1", {
type: "thinking",
thinkingOptionId: "high",
});
expect(notice).toEqual({
notice: {
type: "warning",
message: "Thinking changes apply to the next turn.",
},
});
});
});
describe("toAgentUpdateResult", () => {
it("reports the current thinking option after a metadata update", () => {
const result = toAgentUpdateResult(
{
id: "agent-1",
title: "Renamed agent",
labels: { team: "platform" },
effectiveThinkingOptionId: "high",
},
{ notice: null },
);
expect(result).toEqual({
agentId: "agent-1",
name: "Renamed agent",
labels: "team=platform",
thinkingOptionId: "high",
noticeType: null,
notice: null,
});
});
});

View File

@@ -1,4 +1,6 @@
import type { Command } from "commander";
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
@@ -12,6 +14,9 @@ export interface AgentUpdateResult {
agentId: string;
name: string | null;
labels: string;
thinkingOptionId: string | null;
noticeType: AgentProviderNotice["type"] | null;
notice: string | null;
}
/** Schema for update command output */
@@ -21,17 +26,80 @@ export const updateSchema: OutputSchema<AgentUpdateResult> = {
{ header: "AGENT ID", field: "agentId" },
{ header: "NAME", field: "name" },
{ header: "LABELS", field: "labels" },
{ header: "THINKING", field: "thinkingOptionId" },
{ header: "NOTICE", field: "notice" },
],
};
export interface AgentUpdateOptions extends CommandOptions {
name?: string;
label?: string[];
thinking?: string;
host?: string;
}
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>;
export interface AgentMetadataChanges {
name?: string;
labels?: Record<string, string>;
}
interface AgentUpdateServerInfo {
features?: { agentThinkingUpdate?: boolean };
}
export interface AgentUpdateClient {
getLastServerInfoMessage(): AgentUpdateServerInfo | null;
updateAgent(agentId: string, updates: AgentMetadataChanges): Promise<void>;
setAgentThinkingOption(
agentId: string,
thinkingOptionId: string,
): Promise<AgentProviderNotice | null>;
}
export type AgentChanges =
| { type: "metadata"; updates: AgentMetadataChanges }
| { type: "thinking"; thinkingOptionId: string };
export interface AppliedAgentChanges {
notice: AgentProviderNotice | null;
}
export function toAgentUpdateResult(
agent: Pick<AgentSnapshotPayload, "id" | "title" | "labels" | "effectiveThinkingOptionId">,
appliedChanges: AppliedAgentChanges,
): AgentUpdateResult {
return {
agentId: agent.id,
name: agent.title,
labels: formatLabels(agent.labels),
thinkingOptionId: agent.effectiveThinkingOptionId ?? null,
noticeType: appliedChanges.notice?.type ?? null,
notice: appliedChanges.notice?.message ?? null,
};
}
export async function applyAgentChanges(
client: AgentUpdateClient,
agentId: string,
changes: AgentChanges,
): Promise<AppliedAgentChanges> {
if (changes.type === "thinking") {
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
if (client.getLastServerInfoMessage()?.features?.agentThinkingUpdate !== true) {
throw {
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use agent thinking updates.",
} satisfies CommandError;
}
const notice = await client.setAgentThinkingOption(agentId, changes.thinkingOptionId);
return { notice };
}
await client.updateAgent(agentId, changes.updates);
return { notice: null };
}
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
const parsed: Record<string, string> = {};
if (!labels) {
@@ -81,6 +149,55 @@ function formatLabels(labels: Record<string, string>): string {
return entries.map(([key, value]) => `${key}=${value}`).join(",");
}
function parseAgentChanges(options: AgentUpdateOptions): AgentChanges {
const name = options.name?.trim();
if (options.name !== undefined && !name) {
throw {
code: "INVALID_NAME",
message: "Name cannot be empty",
details: "Use --name <name> with a non-empty value",
} satisfies CommandError;
}
const labels = parseLabelOptions(options.label);
const thinkingOptionId = options.thinking?.trim();
if (options.thinking !== undefined && !thinkingOptionId) {
throw {
code: "INVALID_THINKING_OPTION",
message: "--thinking cannot be empty",
details:
'Provide a thinking option ID. Use "paseo provider models <provider> --thinking" to list valid IDs.',
} satisfies CommandError;
}
const hasMetadataUpdates = Boolean(name) || Object.keys(labels).length > 0;
if (hasMetadataUpdates && thinkingOptionId) {
throw {
code: "INVALID_OPTIONS",
message: "--thinking cannot be combined with --name or --label",
details: "Run separate agent update commands for runtime settings and metadata.",
} satisfies CommandError;
}
if (!hasMetadataUpdates && !thinkingOptionId) {
throw {
code: "NO_CHANGES_PROVIDED",
message: "Nothing to update",
details: "Provide at least one of: --name <name>, --label <key=value>, --thinking <id>",
} satisfies CommandError;
}
if (thinkingOptionId) {
return { type: "thinking", thinkingOptionId };
}
return {
type: "metadata",
updates: {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
},
};
}
export async function runUpdateCommand(
agentIdArg: string,
options: AgentUpdateOptions,
@@ -98,25 +215,7 @@ export async function runUpdateCommand(
throw error;
}
const name = options.name?.trim();
if (options.name !== undefined && !name) {
const error: CommandError = {
code: "INVALID_NAME",
message: "Name cannot be empty",
details: "Use --name <name> with a non-empty value",
};
throw error;
}
const labels = parseLabelOptions(options.label);
if (!name && Object.keys(labels).length === 0) {
const error: CommandError = {
code: "NO_CHANGES_PROVIDED",
message: "Nothing to update",
details: "Provide at least one of: --name <name>, --label <key=value>",
};
throw error;
}
const changes = parseAgentChanges(options);
let client;
try {
@@ -143,10 +242,7 @@ export async function runUpdateCommand(
}
const agentId = fetchResult.agent.id;
await client.updateAgent(agentId, {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
});
const appliedChanges = await applyAgentChanges(client, agentId, changes);
const updatedResult = await client.fetchAgent({ agentId });
if (!updatedResult) {
@@ -157,11 +253,7 @@ export async function runUpdateCommand(
return {
type: "single",
data: {
agentId,
name: updatedResult.agent.title,
labels: formatLabels(updatedResult.agent.labels),
},
data: toAgentUpdateResult(updatedResult.agent, appliedChanges),
schema: updateSchema,
};
} catch (err) {

View File

@@ -18,6 +18,7 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
target?: string;
provider?: string;
mode?: string;
thinking?: string;
cwd?: string;
maxRuns?: string;
expiresIn?: string;
@@ -40,6 +41,7 @@ export async function runCreateCommand(
target: options.target,
provider: options.provider,
mode: options.mode,
thinking: options.thinking,
cwd: options.cwd,
host: options.host,
maxRuns: options.maxRuns,

View File

@@ -32,6 +32,7 @@ export function createScheduleCommand(): Command {
"--mode <mode>",
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
)
.option("--thinking <id>", "Thinking option ID for new-agent runs")
.option("--cwd <path>", "Working directory (default: current; required with --host)")
.option("--run-now", "Fire one immediate run on creation")
.option("--max-runs <n>", "Maximum number of runs")

View File

@@ -116,6 +116,25 @@ describe("parseScheduleCreateInput first-run timing", () => {
});
});
describe("parseScheduleCreateInput thinking", () => {
test("sets the thinking option for each scheduled new-agent run", () => {
const input = parseScheduleCreateInput({
...baseOptions,
cwd: "/project",
thinking: " high ",
});
expect(input.target).toEqual({
type: "new-agent",
config: {
provider: "claude",
cwd: "/project",
thinkingOptionId: "high",
},
});
});
});
describe("parseScheduleUpdateInput", () => {
test("rejects calls with no fields to update", () => {
expect(() => parseScheduleUpdateInput({ id: "abc" })).toThrow(

View File

@@ -114,7 +114,7 @@ function resolveScheduleTarget(args: {
if (hasExplicitNewAgentOption) {
throw {
code: "INVALID_TARGET",
message: "--provider/--mode can only be used with a new-agent target",
message: "--provider/--mode/--thinking can only be used with a new-agent target",
details: "Use --target new-agent or omit --target to create a new agent schedule",
} satisfies CommandError;
}
@@ -144,6 +144,7 @@ export function parseScheduleCreateInput(options: {
target?: string;
provider?: string;
mode?: string;
thinking?: string;
cwd?: string;
host?: string;
maxRuns?: string;
@@ -179,7 +180,15 @@ export function parseScheduleCreateInput(options: {
const targetValue = options.target?.trim();
const modeId = options.mode?.trim();
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
const thinkingOptionId = options.thinking?.trim();
if (options.thinking !== undefined && !thinkingOptionId) {
throw {
code: "INVALID_THINKING_OPTION",
message: "--thinking cannot be empty",
} satisfies CommandError;
}
const hasExplicitNewAgentOption =
options.provider !== undefined || options.mode !== undefined || options.thinking !== undefined;
const createNewAgentTarget = (): ScheduleTarget => {
const resolvedProviderModel = resolveProviderAndModel({
provider: options.provider,
@@ -191,6 +200,7 @@ export function parseScheduleCreateInput(options: {
cwd: cwdInput ?? process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
...(modeId ? { modeId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
},
};
};

View File

@@ -33,6 +33,7 @@ try {
assert.strictEqual(result.exitCode, 0, "agent update --help should exit 0");
assert(result.stdout.includes("--name"), "help should mention --name flag");
assert(result.stdout.includes("--label"), "help should mention --label flag");
assert(result.stdout.includes("--thinking"), "help should mention --thinking flag");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<id>"), "help should mention required id argument");
console.log("✓ agent update --help shows options\n");
@@ -103,6 +104,30 @@ try {
assert(result.stdout.includes("update"), "help should mention update subcommand");
console.log("✓ agent --help shows update subcommand\n");
}
// Test 7: agent update accepts thinking as an update field
{
console.log("Test 7: agent update accepts thinking as an update field");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --thinking high`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("Nothing to update"), "should treat --thinking as an update field");
console.log("✓ agent update accepts thinking as an update field\n");
}
// Test 8: thinking cannot be combined with metadata updates
{
console.log("Test 8: thinking cannot be combined with metadata updates");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name renamed --thinking high`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should reject a combined update");
const output = result.stdout + result.stderr;
assert(
output.includes("--thinking cannot be combined with --name or --label"),
"should explain that thinking and metadata updates are separate operations",
);
console.log("✓ thinking cannot be combined with metadata updates\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true });

View File

@@ -100,6 +100,8 @@ try {
"10m",
"--provider",
"codex/gpt-5.4",
"--thinking",
"high",
"--json",
],
{ timeout: 30000 },
@@ -113,6 +115,7 @@ try {
const inspectedJson = JSON.parse(inspected.stdout);
assert.strictEqual(inspectedJson.target.config.provider, "codex");
assert.strictEqual(inspectedJson.target.config.model, "gpt-5.4");
assert.strictEqual(inspectedJson.target.config.thinkingOptionId, "high");
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);

View File

@@ -8,7 +8,7 @@ import { inheritLoginShellEnv } from "./login-shell-env.js";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { existsSync, statSync } from "node:fs";
import { execFileSync } from "node:child_process";
import {
app,
@@ -40,6 +40,7 @@ import {
buildStandardContextMenuItems,
} from "./window/window-manager.js";
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
import { configureLinuxSandbox } from "./system/linux-sandbox.js";
import { registerDialogHandlers } from "./features/dialogs.js";
import {
registerNotificationHandlers,
@@ -309,12 +310,13 @@ if (forcedUserDataDir) {
}
}
// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID
// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the
// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code.
if (process.platform === "linux" && process.env.APPIMAGE) {
app.commandLine.appendSwitch("no-sandbox");
}
configureLinuxSandbox({
platform: process.platform,
resourcesPath: process.resourcesPath,
statSandbox: (sandboxPath) => statSync(sandboxPath),
disableSandbox: () => app.commandLine.appendSwitch("no-sandbox"),
reportInspectionError: (error) => log.error("[linux-sandbox] failed to inspect helper", error),
});
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { configureLinuxSandbox } from "./linux-sandbox";
interface SandboxMetadata {
mode: number;
uid: number;
}
function configureWithSandbox(
sandbox: SandboxMetadata | Error,
platform: NodeJS.Platform = "linux",
) {
const disabledSwitches: string[] = [];
const inspectionErrors: unknown[] = [];
configureLinuxSandbox({
platform,
resourcesPath: "/opt/Paseo/resources",
statSandbox: () => {
if (sandbox instanceof Error) {
throw sandbox;
}
return sandbox;
},
disableSandbox: () => disabledSwitches.push("no-sandbox"),
reportInspectionError: (error) => inspectionErrors.push(error),
});
return { disabledSwitches, inspectionErrors };
}
function createFileSystemError(code: string): NodeJS.ErrnoException {
const error: NodeJS.ErrnoException = new Error(code);
error.code = code;
return error;
}
describe("configureLinuxSandbox", () => {
it("disables the sandbox when an AppImage mount strips SUID", () => {
expect(configureWithSandbox({ uid: 1000, mode: 0o755 })).toEqual({
disabledSwitches: ["no-sandbox"],
inspectionErrors: [],
});
});
it("keeps the sandbox for a root-owned 4755 helper", () => {
expect(configureWithSandbox({ uid: 0, mode: 0o4755 })).toEqual({
disabledSwitches: [],
inspectionErrors: [],
});
});
it("disables the sandbox when a SUID helper is not root-owned", () => {
expect(configureWithSandbox({ uid: 1000, mode: 0o4755 })).toEqual({
disabledSwitches: ["no-sandbox"],
inspectionErrors: [],
});
});
it("disables the sandbox when the helper is missing", () => {
expect(configureWithSandbox(createFileSystemError("ENOENT"))).toEqual({
disabledSwitches: ["no-sandbox"],
inspectionErrors: [],
});
});
it("keeps the sandbox and reports unexpected inspection failures", () => {
const permissionError = createFileSystemError("EACCES");
expect(configureWithSandbox(permissionError)).toEqual({
disabledSwitches: [],
inspectionErrors: [permissionError],
});
});
it("does not inspect or configure the sandbox outside Linux", () => {
expect(configureWithSandbox(createFileSystemError("EACCES"), "darwin")).toEqual({
disabledSwitches: [],
inspectionErrors: [],
});
});
});

View File

@@ -0,0 +1,45 @@
import path from "node:path";
const REQUIRED_SANDBOX_MODE = 0o4755;
const PERMISSION_BITS = 0o7777;
interface SandboxMetadata {
mode: number;
uid: number;
}
interface LinuxSandboxConfiguration {
platform: NodeJS.Platform;
resourcesPath: string;
statSandbox: (sandboxPath: string) => SandboxMetadata;
disableSandbox: () => void;
reportInspectionError: (error: unknown) => void;
}
function isMissingSandbox(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
export function configureLinuxSandbox(input: LinuxSandboxConfiguration): void {
if (input.platform !== "linux") {
return;
}
try {
const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox");
const sandbox = input.statSandbox(sandboxPath);
const hasUsableSandbox =
sandbox.uid === 0 && (sandbox.mode & PERMISSION_BITS) === REQUIRED_SANDBOX_MODE;
if (!hasUsableSandbox) {
input.disableSandbox();
}
} catch (error) {
if (isMissingSandbox(error)) {
input.disableSandbox();
return;
}
input.reportInspectionError(error);
}
}

View File

@@ -168,4 +168,14 @@ describe("project command-center protocol", () => {
expect(parsed.features?.projectGithubClone).toBeUndefined();
expect(parsed.features?.projectCreateDirectory).toBeUndefined();
});
it("parses the agent thinking update capability", () => {
const parsed = parseServerInfoStatusPayload({
status: "server_info",
serverId: "server-new",
features: { agentThinkingUpdate: true },
});
expect(parsed.features?.agentThinkingUpdate).toBe(true);
});
});

View File

@@ -2792,6 +2792,8 @@ export const ServerInfoStatusPayloadSchema = z
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: z.boolean().optional(),
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
agentThinkingUpdate: z.boolean().optional(),
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
daemonDiagnostics: z.boolean().optional(),
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.

View File

@@ -49,7 +49,7 @@
"speech:download": "tsx scripts/download-speech-models.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",

View File

@@ -3914,8 +3914,8 @@ describe("Codex app-server provider", () => {
},
actions: [
expect.objectContaining({
id: "reject",
label: "Reject",
id: "dismiss",
label: "Dismiss",
behavior: "deny",
}),
expect.objectContaining({
@@ -3979,6 +3979,216 @@ describe("Codex app-server provider", () => {
});
});
test("replaces a pending synthetic plan approval when a later plan completes", () => {
const session = createSession({
featureValues: { plan_mode: true },
});
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-first" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the first plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-second" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the revised plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(session.getPendingPermissions()).toEqual([
expect.objectContaining({
kind: "plan",
input: { plan: "- Implement the revised plan" },
}),
]);
});
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const pendingPlan = session.getPendingPermissions()[0];
expect(pendingPlan).toBeDefined();
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") return {};
throw new Error(`Unexpected request: ${method}`);
},
});
await session.startTurn("Revise the plan to include tests");
expect(session.getPendingPermissions()).toEqual([]);
expect(events).toContainEqual({
type: "permission_resolved",
provider: "codex",
requestId: pendingPlan!.id,
resolution: {
behavior: "deny",
message: "Dismissed by a new prompt",
},
});
});
test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const pendingPlan = session.getPendingPermissions()[0];
expect(pendingPlan).toBeDefined();
let continuePromptSetup: (() => void) | undefined;
let markPromptSetupStarted: (() => void) | undefined;
const promptSetupStarted = new Promise<void>((resolve) => {
markPromptSetupStarted = resolve;
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") {
markPromptSetupStarted?.();
await new Promise<void>((resolve) => {
continuePromptSetup = resolve;
});
return { data: ["test-thread"] };
}
if (method === "turn/start") return {};
throw new Error(`Unexpected request: ${method}`);
},
});
const startTurn = session.startTurn("Revise the plan");
await promptSetupStarted;
expect(session.getPendingPermissions()).toEqual([]);
await expect(
session.respondToPermission(pendingPlan!.id, {
behavior: "allow",
selectedActionId: "implement",
}),
).rejects.toThrow(
`No pending Codex app-server permission request with id '${pendingPlan!.id}'`,
);
continuePromptSetup?.();
await startTurn;
});
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
let acceptPrompt: (() => void) | undefined;
let markPromptRequested: (() => void) | undefined;
const promptRequested = new Promise<void>((resolve) => {
markPromptRequested = resolve;
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") {
markPromptRequested?.();
return await new Promise<void>((resolve) => {
acceptPrompt = resolve;
});
}
throw new Error(`Unexpected request: ${method}`);
},
});
const startTurn = session.startTurn("Revise the plan");
await promptRequested;
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the newer plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
acceptPrompt?.();
await startTurn;
expect(session.getPendingPermissions()).toEqual([
expect.objectContaining({ input: { plan: "- Implement the newer plan" } }),
]);
});
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const pendingPlan = session.getPendingPermissions()[0];
expect(pendingPlan).toBeDefined();
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") throw new Error("Prompt rejected");
throw new Error(`Unexpected request: ${method}`);
},
});
await expect(session.startTurn("Revise the plan")).rejects.toThrow("Prompt rejected");
expect(session.getPendingPermissions()).toEqual([]);
});
test("emits imageView paths with spaces as valid assistant markdown images", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];

View File

@@ -963,8 +963,8 @@ function buildPlanPermissionActions(options?: {
}): AgentPermissionAction[] {
const actions: AgentPermissionAction[] = [
{
id: "reject",
label: "Reject",
id: "dismiss",
label: "Dismiss",
behavior: "deny",
variant: "danger",
intent: "dismiss",
@@ -3092,6 +3092,13 @@ interface CodexSubAgentCallState {
childThreadIds: Set<string>;
}
interface CodexPendingPermissionHandler {
resolve: (value: unknown) => void;
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
questions?: CodexQuestionPrompt[];
planText?: string;
}
export class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
@@ -3115,15 +3122,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private mcpElicitationPermissionIds = new Map<number, string>();
private pendingPermissionHandlers = new Map<
string,
{
resolve: (value: unknown) => void;
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
questions?: CodexQuestionPrompt[];
planText?: string;
}
>();
private pendingPermissionHandlers = new Map<string, CodexPendingPermissionHandler>();
private resolvedPermissionRequests = new Set<string>();
private pendingAgentMessages = new Map<string, string>();
private pendingReasoning = new Map<string, string[]>();
@@ -3430,6 +3429,8 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private emitSyntheticPlanApprovalRequest(planText: string): void {
this.dismissPendingPlanApprovals("Superseded by a newer plan");
const requestId = `permission-${randomUUID()}`;
const request: AgentPermissionRequest = {
id: requestId,
@@ -3837,30 +3838,31 @@ export class CodexAppServerAgentSession implements AgentSession {
throw new Error("A foreground turn is already active");
}
await this.connect();
if (!this.client) {
throw new Error("Codex client not initialized");
}
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
const effectivePrompt = slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: prompt;
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
const turnStart = await this.buildTurnStartParams(effectivePrompt, options);
const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.currentTurnId = null;
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
try {
await this.connect();
if (!this.client) {
throw new Error("Codex client not initialized");
}
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
const effectivePrompt = slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: prompt;
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
const turnStart = await this.buildTurnStartParams(effectivePrompt, options);
const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.currentTurnId = null;
this.logTurnStartSummary({
turnId,
thinkingOptionId: turnStart.thinkingOptionId,
@@ -3871,13 +3873,12 @@ export class CodexAppServerAgentSession implements AgentSession {
hasCodexConfig: turnStart.hasCodexConfig,
});
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
return { turnId };
} catch (error) {
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
throw error;
}
return { turnId };
}
private rememberCodexUserMessageTurn(messageId: string | null | undefined): boolean {
@@ -4128,12 +4129,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private handlePlanPermissionResponse(params: {
requestId: string;
response: AgentPermissionResponse;
pending: {
resolve: (value: unknown) => void;
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
questions?: CodexQuestionPrompt[];
planText?: string;
};
pending: CodexPendingPermissionHandler;
pendingRequest: AgentPermissionRequest | null;
}): AgentPermissionResult | void {
const { requestId, response, pending, pendingRequest } = params;
@@ -4144,6 +4140,23 @@ export class CodexAppServerAgentSession implements AgentSession {
});
}
this.resolvePlanPermission(requestId, response);
if (followUpPrompt) {
return { followUpPrompt };
}
}
private dismissPendingPlanApprovals(message: string): void {
const requestIds = Array.from(this.pendingPermissionHandlers)
.filter(([, pending]) => pending.kind === "plan")
.map(([requestId]) => requestId);
for (const requestId of requestIds) {
this.resolvePlanPermission(requestId, { behavior: "deny", message });
}
}
private resolvePlanPermission(requestId: string, resolution: AgentPermissionResponse): void {
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
@@ -4151,11 +4164,8 @@ export class CodexAppServerAgentSession implements AgentSession {
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: response,
resolution,
});
if (followUpPrompt) {
return { followUpPrompt };
}
}
private emitDeniedToolCallTimelineEvent(params: {

View File

@@ -1540,6 +1540,8 @@ export class VoiceAssistantWebSocketServer {
providerUsageList: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: true,
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
agentThinkingUpdate: true,
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
daemonDiagnostics: true,
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.

View File

@@ -253,7 +253,7 @@ function lastNonEmptyLineIsPrompt(state: ReturnType<TerminalSession["getState"]>
}
function removeZshShellIntegrationRuntimeDir(): void {
rmSync(join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh`), {
rmSync(join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh-${process.pid}`), {
recursive: true,
force: true,
});
@@ -272,7 +272,9 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
expect(resolvedEnv.TERM).toBe("xterm-256color");
expect(resolvedEnv.TERM_PROGRAM).toBe("kitty");
expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir");
expect(resolvedEnv.ZDOTDIR).not.toBe("/tmp/paseo-zdotdir");
expect(resolvedEnv.ZDOTDIR).toBe(
join(tmpdir(), `${userInfo().username || "unknown"}-paseo-zsh-${process.pid}`),
);
expect(existsSync(join(resolvedEnv.ZDOTDIR, ".zshenv"))).toBe(true);
expect(existsSync(join(resolvedEnv.ZDOTDIR, "paseo-integration.zsh"))).toBe(true);
});

View File

@@ -384,7 +384,7 @@ function resolveZshShellIntegrationRuntimeDir(): string {
} catch {
// keep fallback
}
return join(tmpdir(), `${username}-paseo-zsh`);
return join(tmpdir(), `${username}-paseo-zsh-${process.pid}`);
}
function prepareZshShellIntegrationRuntimeDir(sourceDir = resolveZshShellIntegrationDir()): string {

View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const workflowPath = new URL("../.github/workflows/ci.yml", import.meta.url);
function jobBlocks(source) {
const jobs = new Map();
let currentJob;
for (const line of source.split("\n")) {
const jobMatch = /^ ([a-z0-9-]+):\s*$/.exec(line);
if (jobMatch) {
currentJob = jobMatch[1];
jobs.set(currentJob, []);
continue;
}
if (currentJob && (/^ \S/.test(line) || /^ \S/.test(line))) {
jobs.get(currentJob).push(line);
}
}
return jobs;
}
test("matrix jobs expand before change gating", () => {
const workflow = readFileSync(workflowPath, "utf8");
const gatedMatrixJobs = [...jobBlocks(workflow)]
.filter(([, lines]) => {
const hasMatrix = lines.some((line) => line.startsWith(" matrix:"));
const unsafeJobCondition = lines.some(
(line) => line.startsWith(" if:") && line.trim() !== "if: ${{ !cancelled() }}",
);
return hasMatrix && unsafeJobCondition;
})
.map(([jobId]) => jobId);
assert.deepEqual(
gatedMatrixJobs,
[],
"change-based job conditions skip a matrix before GitHub can emit its interpolated check names",
);
});
test("change gating allows superseded workflow runs to cancel", () => {
const workflow = readFileSync(workflowPath, "utf8");
const cancellationBlockingJobs = [...jobBlocks(workflow)]
.filter(([, lines]) => lines.some((line) => line.trim().startsWith("${{ always()")))
.map(([jobId]) => jobId);
assert.deepEqual(
cancellationBlockingJobs,
[],
"always() keeps jobs alive after concurrency cancellation; use !cancelled() for fail-open gating",
);
});