From d7e2ab44371ea9f44a85eef9ddaa29c3a6c4627a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 21 Feb 2026 22:09:48 +0700 Subject: [PATCH] feat: add thinking option support and autonomous run handling --- CLAUDE.md | 20 + CLAUDE_PROVIDER_REDESIGN_PLAN.md | 112 ++ app.json | 5 + packages/app/src/contexts/session-context.tsx | 8 +- .../src/screens/agent/draft-agent-screen.tsx | 36 +- packages/cli/src/cli.ts | 28 +- packages/cli/src/commands/agent/index.ts | 2 + packages/cli/src/commands/agent/inspect.ts | 16 +- packages/cli/src/commands/agent/ls.ts | 67 +- packages/cli/src/commands/agent/run.ts | 13 + packages/cli/src/commands/daemon/index.ts | 2 + .../src/commands/daemon/runtime-toolchain.ts | 131 ++ packages/cli/src/commands/daemon/status.ts | 204 ++- packages/cli/src/commands/daemon/update.ts | 105 ++ packages/cli/src/commands/provider/index.ts | 1 + packages/cli/src/commands/provider/models.ts | 34 +- .../relay/src/dist-handshake-parity.test.ts | 21 + packages/relay/src/encrypted-channel.ts | 30 +- .../src/server/agent/agent-manager.test.ts | 241 +++ .../server/src/server/agent/agent-manager.ts | 115 +- .../src/server/agent/agent-projections.ts | 25 + .../src/server/agent/agent-sdk-types.ts | 1 + .../server/src/server/agent/agent-storage.ts | 1 + ...agent.interrupt-restart-regression.test.ts | 840 +++++++-- .../providers/claude-agent.redesign.test.ts | 800 +++++++++ .../server/agent/providers/claude-agent.ts | 1504 +++++++++++++---- .../claude/tool-call-detail-parser.ts | 6 + .../providers/claude/tool-call-mapper.test.ts | 19 + .../providers/claude/tool-call-mapper.ts | 2 + .../agent/providers/codex-app-server-agent.ts | 28 +- .../providers/tool-call-detail-primitives.ts | 1 + .../claude-autonomous-wake.real.e2e.test.ts | 811 +++++++++ packages/server/src/server/relay-transport.ts | 24 +- packages/server/src/server/session.ts | 38 +- packages/server/src/shared/messages.ts | 3 + 35 files changed, 4701 insertions(+), 593 deletions(-) create mode 100644 CLAUDE_PROVIDER_REDESIGN_PLAN.md create mode 100644 app.json create mode 100644 packages/cli/src/commands/daemon/runtime-toolchain.ts create mode 100644 packages/cli/src/commands/daemon/update.ts create mode 100644 packages/relay/src/dist-handshake-parity.test.ts create mode 100644 packages/server/src/server/agent/providers/claude-agent.redesign.test.ts create mode 100644 packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5cd372179..0c2280a8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,26 @@ Use `npm run cli` to run the local CLI (instead of the globally linked `paseo` w Use `--host ` to point the CLI at a different daemon (e.g., `--host localhost:7777`). +### Relay build sync (important) + +When changing `packages/relay/src/*`, rebuild relay before running/debugging the daemon: + +```bash +npm run build --workspace=@getpaseo/relay +``` + +Reason: Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*` (`node` export path), not directly from `src/*`. + +### Server build sync for CLI (important) + +When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild server before running/debugging CLI commands: + +```bash +npm run build --workspace=@getpaseo/server +``` + +Reason: local CLI imports `@getpaseo/server` via package exports that resolve to `packages/server/dist/*` first. If `dist` is stale, CLI can speak an old protocol (for example, sending `session` before `hello`) and fail with handshake warnings/timeouts. + ### Quick reference CLI commands ```bash diff --git a/CLAUDE_PROVIDER_REDESIGN_PLAN.md b/CLAUDE_PROVIDER_REDESIGN_PLAN.md new file mode 100644 index 000000000..d0c79a0b3 --- /dev/null +++ b/CLAUDE_PROVIDER_REDESIGN_PLAN.md @@ -0,0 +1,112 @@ +# Claude Provider Redesign Plan + +## Goal +Eliminate dropped/misrouted Claude messages and stuck lifecycle transitions under foreground/autonomous interleaving by replacing heuristics with an explicit state machine and deterministic routing. + +## Non-Negotiable Constraints +1. Single-owner SDK consumption: only one internal pump calls `query.next()`. +2. Task notifications are metadata only, never lifecycle correctness dependencies. +3. Interrupt must work when lifecycle is `running`, even with no foreground `pendingRun`. +4. Public API semantics unchanged: + - `stream`, `streamHistory`, `interrupt` signatures remain. + - `waitForFinish` remains status-based (`running -> idle/error/permission/timeout`). + +## Final Architecture + +### 1) Session Turn Ownership State +`turnState`: `idle | foreground | autonomous` + +Transitions: +1. `idle -> foreground` on user `stream()` send. +2. `foreground -> idle` on foreground terminal event. +3. `idle -> autonomous` on first routable autonomous SDK event. +4. `autonomous -> idle` when autonomous run(s) terminal. +5. `autonomous -> foreground` must be atomic: + - interrupt autonomous + - await teardown + - start foreground + +### 2) Run Lifecycle Tracking +Per-run state (tracked independently from timeline): +`queued | awaiting_response | streaming | finalizing | completed | interrupted | error` + +Session status derives from runs: +- `running` iff any run is in `queued|awaiting_response|streaming|finalizing` +- `permission` iff permission pending +- `error` iff errored +- `idle` otherwise + +### 3) Deterministic Event Routing (single decision point) +In the query pump, normalize each SDK event, then route by this order: + +1. If `turnState=foreground`: route all non-metadata events to foreground run queue. +2. Else route by identifiers: + - `task_id` + - `parent_message_id` + - `message_id` + - fallback: create autonomous run +3. If no route: metadata-only event. + +No timestamp windows. No suppression/diversion heuristics. + +### 4) Timeline Assembly Decoupled from Run Ownership +Timeline is assembled from normalized message events keyed by `message_id`, independent of run ownership: + +1. `message_start` -> create/update timeline entry +2. `message_delta` -> append +3. `message_stop` -> finalize +4. Support out-of-order events (placeholder then reconcile) + +Guarantee: assistant messages cannot be dropped from timeline due routing ambiguity. + +## Refactor Sequence (Low-Risk Migration) +1. Introduce explicit types: + - `TurnState` + - `RunTracker` (run map + lifecycle) + - `TimelineAssembler` (message-id based) +2. Refactor pump flow to: + - normalize event + - route via single decision point + - update run tracker + - always feed timeline assembler +3. Implement atomic `autonomous -> foreground` transition. +4. Remove old heuristics: + - suppression windows + - pre-prompt diversion/grace logic + - task-notification lifecycle branching +5. Keep manager/provider external contracts unchanged. + +## Correctness Invariants +1. Only query pump calls `query.next()`. +2. Foreground exclusivity: while `turnState=foreground`, non-metadata SDK events cannot route to autonomous path. +3. Lifecycle correctness: `running` emitted when any run active; terminal transitions settle back to `idle`. +4. Interrupt correctness: `cancelAgentRun()` works for autonomous running with no foreground `pendingRun`. +5. Timeline completeness: every assistant `message_start/delta/stop` appears in timeline. + +## Test Plan + +### Unit Tests +1. Deterministic routing by identifier priority. +2. Foreground exclusivity under interleaving events. +3. Run lifecycle transitions (success, interrupt, error). +4. Timeline assembler out-of-order robustness. +5. Manager autonomous cancel path without foreground `pendingRun`. + +### Real Claude E2E Gates +1. `repro: do-it-again + immediate hello can hang after autonomous wake under churn` + - acceptance: autonomous `running` transition observed within timeout each cycle. +2. `repro: transcript/timeline parity after do-it-again + hello race` + - acceptance: assistant messages present in transcript (including HELLO and autonomous completion) are present in timeline. + +## Rollout / Risk Controls +1. Add temporary structured debug logs for: + - turn state transitions + - routing decisions (`event -> run`) + - run lifecycle transitions + - timeline assembly (`message_id`) +2. Keep clear rollback boundaries by incremental commits: + - types + scaffolding + - routing replacement + - heuristic removal + - test hardening +3. Run `npm run -w packages/server typecheck` and targeted test suites after each stage. diff --git a/app.json b/app.json new file mode 100644 index 000000000..81b32f17c --- /dev/null +++ b/app.json @@ -0,0 +1,5 @@ +{ + "android": { + "package": "com.moboudra.paseo" + } +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 005c62e01..014b70729 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -150,11 +150,9 @@ function SessionProviderWithClient({ client, }: SessionProviderClientProps) { return ( - + + {children} + ); } diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 2cb8522c7..b19bbeef8 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -378,13 +378,7 @@ function DraftAgentScreenContent({ const sessionClient = runtimeClient const trimmedWorkingDir = workingDir.trim() const shouldInspectRepo = trimmedWorkingDir.length > 0 - const daemonAvailabilityError = - !selectedServerId || !isHostOnline ? 'Host is offline' : null - const repoAvailabilityError = - shouldInspectRepo && !isHostOnline - ? (daemonAvailabilityError ?? - 'Repository details will load automatically once the selected host is back online.') - : null + const canQuerySelectedHost = Boolean(selectedServerId) && Boolean(sessionClient) && isHostOnline const checkoutStatusQuery = useQuery({ queryKey: checkoutStatusQueryKey(selectedServerId ?? '', trimmedWorkingDir), @@ -395,12 +389,7 @@ function DraftAgentScreenContent({ } return await client.getCheckoutStatus(trimmedWorkingDir) }, - enabled: - Boolean(selectedServerId) && - Boolean(trimmedWorkingDir) && - !repoAvailabilityError && - Boolean(sessionClient) && - isHostOnline, + enabled: Boolean(trimmedWorkingDir) && canQuerySelectedHost, retry: false, staleTime: CHECKOUT_STATUS_STALE_TIME, refetchOnMount: 'always', @@ -424,8 +413,8 @@ function DraftAgentScreenContent({ const repoInfoStatus: 'idle' | 'loading' | 'ready' | 'error' = !shouldInspectRepo ? 'idle' - : repoAvailabilityError - ? 'error' + : !canQuerySelectedHost + ? 'idle' : checkoutStatusQuery.isPending || checkoutStatusQuery.isFetching ? 'loading' : checkoutStatusQuery.isError || Boolean(checkoutPayloadError) @@ -435,9 +424,7 @@ function DraftAgentScreenContent({ : 'idle' const repoInfoError = - repoAvailabilityError ?? - (checkoutStatusQuery.isError ? checkoutQueryError : null) ?? - checkoutPayloadError + (checkoutStatusQuery.isError ? checkoutQueryError : null) ?? checkoutPayloadError const isCreateWorktree = worktreeMode === 'create' const isAttachWorktree = worktreeMode === 'attach' @@ -461,9 +448,7 @@ function DraftAgentScreenContent({ enabled: isGitDirectory && Boolean(worktreeListRoot) && - !repoAvailabilityError && - Boolean(sessionClient) && - isHostOnline && + canQuerySelectedHost && !isNonGitDirectory, retry: false, staleTime: 0, @@ -526,9 +511,7 @@ function DraftAgentScreenContent({ isGitDirectory && !isNonGitDirectory && Boolean(trimmedWorkingDir) && - !repoAvailabilityError && - Boolean(sessionClient) && - isHostOnline, + canQuerySelectedHost, retry: false, staleTime: 15_000, }) @@ -558,10 +541,7 @@ function DraftAgentScreenContent({ }, enabled: Boolean(debouncedWorkingDirSearchQuery) && - Boolean(selectedServerId) && - !daemonAvailabilityError && - Boolean(sessionClient) && - isHostOnline, + canQuerySelectedHost, retry: false, staleTime: 15_000, }) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7a7cc77c9..fede55476 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,6 +9,7 @@ import { createWorktreeCommand } from './commands/worktree/index.js' import { startCommand as daemonStartCommand } from './commands/daemon/start.js' import { runStatusCommand as runDaemonStatusCommand } from './commands/daemon/status.js' import { runRestartCommand as runDaemonRestartCommand } from './commands/daemon/restart.js' +import { runDaemonUpdateCommandOrExit } from './commands/daemon/update.js' import { runLsCommand } from './commands/agent/ls.js' import { runRunCommand } from './commands/agent/run.js' import { runLogsCommand } from './commands/agent/logs.js' @@ -17,7 +18,6 @@ import { runSendCommand } from './commands/agent/send.js' import { runInspectCommand } from './commands/agent/inspect.js' import { runWaitCommand } from './commands/agent/wait.js' import { runAttachCommand } from './commands/agent/attach.js' -import { runUpdateCommand } from './commands/agent/update.js' import { withOutput } from './output/index.js' import { onboardCommand } from './commands/onboard.js' @@ -63,6 +63,7 @@ export function createCli(): Command { .option('-a, --all', 'Include archived agents') .option('-g, --global', 'Legacy no-op (kept for compatibility)') .option('--label ', 'Filter by label (can be used multiple times)', collectMultiple, []) + .option('--thinking ', 'Filter by thinking option ID') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runLsCommand)) @@ -75,6 +76,7 @@ export function createCli(): Command { .option('--name ', 'Assign a name/title to the agent') .option('--provider ', 'Agent provider: claude | codex | opencode', 'claude') .option('--model ', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)') + .option('--thinking ', 'Thinking option ID to use for this run') .option('--mode ', 'Provider-specific mode (e.g., plan, default, bypass)') .option('--worktree ', 'Create agent in a new git worktree') .option('--base ', 'Base branch for worktree (default: current branch)') @@ -143,25 +145,19 @@ export function createCli(): Command { .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runWaitCommand)) - program - .command('update') - .description('Update an agent (alias for "paseo agent update")') - .argument('', 'Agent ID (or prefix)') - .option('--name ', "Update the agent's display name") - .option( - '--label