Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions .agents/skills/interactive-testing/scripts/poracode-cdp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,15 @@ async function waitForTarget(timeoutMs) {
// A window's kind never changes, so remember each probed target across poll
// iterations instead of re-opening a WebSocket to it every second.
const probedKindsByTargetId = new Map();
let lastTargets = null;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(`http://127.0.0.1:${port}/json/list`);
if (res.ok) {
const targets = await res.json();
const candidates = targets.filter((t) => t.type === "page" && t.url === appUrl);
lastTargets = targets;
const pageTargets = targets.filter((t) => t.type === "page");
const candidates = pageTargets.filter((t) => t.url === appUrl);
if (!windowKind && candidates[0]) return candidates[0];
for (const target of candidates) {
let kind = probedKindsByTargetId.get(target.id);
Expand All @@ -296,12 +299,37 @@ async function waitForTarget(timeoutMs) {
}
if (kind === windowKind) return target;
}
// CDP is up and serving targets, but none match the expected URL.
// Fail fast instead of polling — the URL won't change.
if (pageTargets.length > 0) {
const available = pageTargets.map((t) => t.url).join(", ");
throw new Error(
`no app CDP target matching ${appUrl} on port ${port}. ` +
`Available page targets: ${available}. ` +
`Set PORACODE_APP_URL to the correct URL.`,
);
}
}
} catch {
} catch (err) {
// If we threw the fast-fail error above, propagate it.
if (err?.message?.includes("no app CDP target matching")) throw err;
// CDP endpoint not up yet — keep polling.
}
await new Promise((r) => setTimeout(r, 1000));
}
// Timeout reached. If CDP was serving targets but none matched, give a
// diagnostic instead of a bare null.
if (lastTargets) {
const pageTargets = lastTargets.filter((t) => t.type === "page");
if (pageTargets.length > 0) {
const available = pageTargets.map((t) => t.url).join(", ");
throw new Error(
`no app CDP target matching ${appUrl} on port ${port}. ` +
`Available page targets: ${available}. ` +
`Set PORACODE_APP_URL to the correct URL.`,
);
}
}
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -658,12 +658,15 @@ async function skillsSectionDeepDive(client) {
async function mcpServersSectionDeepDive(client, mcpFixture) {
const mcpState = await evaluate(
client,
`(() => ({
builtInsVisible: document.body.innerText.includes("Built-in MCP servers"),
builtInToolCount: document.body.innerText.includes("44 tools"),
addButton: Boolean([...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Add MCP server")),
browserSwitch: Boolean(document.querySelector('[role="switch"][aria-label="Disable Browser"]')),
}))()`,
`(() => {
const browserRow = document.querySelector('[data-built-in-mcp-server="browser"]');
return {
builtInsVisible: document.body.innerText.includes("Built-in MCP servers"),
builtInToolCount: /\\b\\d+ tools?\\b/.test(browserRow?.textContent ?? ""),
addButton: Boolean([...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Add MCP server")),
browserSwitch: Boolean(document.querySelector('[role="switch"][aria-label="Disable Browser"]')),
};
})()`,
);
assert(mcpState.builtInsVisible, "MCP settings did not render built-in servers");
assert(mcpState.builtInToolCount, "MCP settings did not render built-in tool counts");
Expand Down Expand Up @@ -1512,21 +1515,37 @@ async function installWindowErrorCollector(client) {

async function waitForTarget() {
const started = Date.now();
let cdpRespondedWithPages = false;
let lastPageUrls = [];
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
if (response.ok) {
const targets = await response.json();
const target = targets.find(
(candidate) => candidate.type === "page" && candidate.url === appUrl,
);
const pageTargets = targets.filter((t) => t.type === "page");
const target = pageTargets.find((candidate) => candidate.url === appUrl);
if (target) return target;
// CDP is up with page targets but none match — the renderer loaded
// on a different URL than expected. Fail fast instead of polling.
if (pageTargets.length > 0) {
cdpRespondedWithPages = true;
lastPageUrls = pageTargets.map((t) => t.url);
}
}
} catch {
// Electron is still starting.
}
// Once CDP serves page targets that don't match, the URL won't change.
if (cdpRespondedWithPages) break;
await new Promise((resolveWait) => setTimeout(resolveWait, 500));
}
if (cdpRespondedWithPages) {
throw new Error(
`no Poracode CDP target matching ${appUrl} on port ${port}. ` +
`Available page targets: ${lastPageUrls.join(", ")}. ` +
`Check PORACODE_APP_URL / port allocation.`,
);
}
throw new Error(`no Poracode CDP target at ${appUrl} on port ${port}`);
}

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ resources/wsl-helpers/watcher.node
resources/wsl-helpers/bridge.mjs
resources/wsl-helpers/mcp-probe.mjs
resources/wsl-helpers/mcp-filter.mjs
resources/wsl-helpers/cursor-sdk-worker.mjs
resources/agent-plugins/
resources/mobile-ssh-runtime/
.envrc
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"dev:renderer": "node scripts/free-port.mjs && vite",
"dev:mobile": "node scripts/free-port.mjs 3100 && cross-env PORACODE_BUILD_TARGET=mobile vite",
"dev:electron": "tsdown --watch",
"dev:app": "pnpm run setup:native && wait-on dist/main/main.cjs dist/main/mcpProbeWorker.mjs && pnpm run prepare:package-assets && node scripts/wait-dev-server.mjs && node scripts/dev-launch.mjs",
"dev:app": "pnpm run setup:native && wait-on dist/main/main.cjs dist/main/mcpProbeWorker.mjs dist/main/cursorSdkWorker.mjs && pnpm run prepare:package-assets && node scripts/wait-dev-server.mjs && node scripts/dev-launch.mjs",
"build": "pnpm run build:renderer && pnpm run build:electron",
"build:renderer": "vite build",
"build:mobile": "pnpm run prepare:mobile:ssh && cross-env PORACODE_BUILD_TARGET=mobile vite build && node scripts/finalize-mobile-build.mjs",
Expand Down
1 change: 1 addition & 0 deletions scripts/build-desktop-artifact.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ asarUnpack:
- node_modules/node-pty/**/*
- node_modules/better-sqlite3/**/*
- dist/main/claudeSdkProbeWorker.mjs
- dist/main/cursorSdkWorker.mjs
- node_modules/@anthropic-ai/claude-agent-sdk/**/*

afterPack: build/after-pack.cjs
Expand Down
20 changes: 17 additions & 3 deletions scripts/prepare-wsl-helpers.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Stages every Node helper that we run *inside* a WSL distro into
* `resources/wsl-helpers/` so electron-builder can bundle them as
* extraResources. Two artefacts ride this pipeline:
* extraResources. Five artefacts ride this pipeline:
*
* 1. `watcher.node` — @parcel/watcher Linux x64 native binding,
* downloaded via `npm pack`. Loaded by `bridge.mjs` for watch
Expand All @@ -11,6 +11,8 @@
* 3. `mcp-probe.mjs` — self-contained MCP client used to verify workspace
* servers in the same distro where providers run.
* 4. `mcp-filter.mjs` — same-environment MCP proxy that removes disabled tools.
* 5. `cursor-sdk-worker.mjs` — isolated transport shell that dynamically
* imports a Cursor SDK installed inside the target distro.
*
* Idempotency: presence + non-zero size on `watcher.node` skips the
* `npm pack` download. `bridge.mjs` is always copied — the copy is <1ms
Expand Down Expand Up @@ -45,6 +47,7 @@ stageWatcherBinary();
stageHookBridge();
stageMcpProbe();
stageMcpFilter();
stageCursorSdkWorker();

function stageWatcherBinary() {
const dest = join(destDir, "watcher.node");
Expand Down Expand Up @@ -117,12 +120,23 @@ function stageMcpFilter() {
console.log(`[prepare-wsl-helpers] mcpToolFilterWorker.mjs -> ${dest}`);
}

function assertSelfContainedWorker(path) {
function stageCursorSdkWorker() {
const src = join(repoRoot, "dist", "main", "cursorSdkWorker.mjs");
if (!existsSync(src)) {
throw new Error(`Cursor SDK worker missing; run build:electron first: ${src}`);
}
assertSelfContainedWorker(src, "Cursor SDK worker");
const dest = join(destDir, "cursor-sdk-worker.mjs");
copyFileSync(src, dest);
console.log(`[prepare-wsl-helpers] cursorSdkWorker.mjs -> ${dest}`);
}

function assertSelfContainedWorker(path, label = "MCP probe worker") {
const source = readFileSync(path, "utf8");
const imports = source.matchAll(/^import(?:[\s\S]*?\sfrom\s*)?["']([^"']+)["'];?$/gm);
const builtins = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]);
const external = [...imports].map((match) => match[1]).filter((name) => !builtins.has(name));
if (external.length > 0) {
throw new Error(`MCP probe worker is not self-contained: ${[...new Set(external)].join(", ")}`);
throw new Error(`${label} is not self-contained: ${[...new Set(external)].join(", ")}`);
}
}
2 changes: 1 addition & 1 deletion scripts/refresh-node-checksums.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*
* Run after bumping `PORACODE_PINNED_NODE_VERSION` in that file:
*
* pnpm tsx scripts/refresh-node-checksums.mjs
* node scripts/refresh-node-checksums.mjs
*
* Covers every target poracode ships a managed-runtime install for:
* - linux-x64 / linux-arm64 (.tar.xz) — WSL + native Linux
Expand Down
2 changes: 2 additions & 0 deletions src/main/ssh/SshConnectionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe("SSH runtime bundle", () => {
"server.cjs",
"supervisor.cjs",
"claudeSdkProbeWorker.mjs",
"cursorSdkWorker.mjs",
"transcriptReader-generated.cjs",
]) {
writeFileSync(join(mainBundleDir, file), file, "utf8");
Expand All @@ -116,6 +117,7 @@ describe("SSH runtime bundle", () => {
const archiveName = basename(bundle.archivePath);
const entries = execFileSync(tar, ["-tzf", archiveName], { cwd: archiveDir }).toString("utf8");
expect(entries).toContain("transcriptReader-generated.cjs");
expect(entries).toContain("cursorSdkWorker.mjs");
const packageEntry = execFileSync(tar, ["-xOf", archiveName, "./package.json"], {
cwd: archiveDir,
}).toString("utf8");
Expand Down
7 changes: 6 additions & 1 deletion src/main/ssh/runtimeBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,12 @@ export function ensureSshRuntimeBundle(options: SshRuntimeBundleOptions): SshRun
}
}

const requiredFiles = ["server.cjs", "supervisor.cjs", "claudeSdkProbeWorker.mjs"];
const requiredFiles = [
"server.cjs",
"supervisor.cjs",
"claudeSdkProbeWorker.mjs",
"cursorSdkWorker.mjs",
];
for (const file of requiredFiles) {
const source = join(options.mainBundleDir, file);
if (!existsSync(source)) {
Expand Down
17 changes: 13 additions & 4 deletions src/mobile/ComposerActionDocks.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AgentStatus, Project, Thread } from "@/shared/contracts";
import { agentStatusForPresentation } from "@/shared/agentSelection";
import {
changeThreadConfig,
clearThreadPendingSteer,
Expand Down Expand Up @@ -36,21 +37,29 @@ export function ComposerActionDocks(props: {
readonly onOpenPlanFile?: ((path: string) => void) | undefined;
}) {
const { thread, agentStatus, project } = props;
const presentationMode =
thread.presentationMode ?? agentStatus?.capabilities.presentationMode ?? "terminal";
const effectiveAgentStatus = agentStatus
? agentStatusForPresentation(agentStatus, presentationMode, thread.sessionRef)
: undefined;
const request = useAppStore((state) => state.runtimeRequestsByThread[thread.id]?.[0]);
const pendingSteer = useDelayedPendingSteer(
useAppStore((state) => state.pendingSteerByThreadId[thread.id]),
);
const { authRequired } = resolveThreadAuthState({
authState: agentStatus?.authState,
authState: effectiveAgentStatus?.authState,
errorDockStates: props.dockState.errorDockStates,
});
const showAuthDock = authRequired && agentStatus !== undefined;
const showAuthDock = authRequired && effectiveAgentStatus !== undefined;
if (!showAuthDock && !pendingSteer && !request) return null;

return (
<div className="m-thread-action-docks">
{showAuthDock ? (
<ThreadAuthRequiredDock agentStatus={agentStatus} {...(project ? { project } : {})} />
<ThreadAuthRequiredDock
agentStatus={effectiveAgentStatus}
{...(project ? { project } : {})}
/>
) : null}
{pendingSteer ? (
<ThreadPendingSteerStrip
Expand All @@ -62,7 +71,7 @@ export function ComposerActionDocks(props: {
<ThreadRuntimeRequestPanel
key={request.requestId}
threadId={thread.id}
agentLabel={agentStatus?.label}
agentLabel={effectiveAgentStatus?.label}
request={request}
onResolve={(input) => resolveThreadServerRequest(thread.id, input)}
onPlanApproved={(optionId) =>
Expand Down
19 changes: 10 additions & 9 deletions src/mobile/ComposerCompactSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import type { AgentStatus, Thread } from "@/shared/contracts";
import { capabilitiesForPresentation } from "@/shared/agentSelection";
import { agentStatusForPresentation } from "@/shared/agentSelection";
import { ProviderIcon } from "@/renderer/components/providers/ProviderIcon";
import { getComposerControls } from "@/renderer/components/providers/providerComposer";
import {
Expand All @@ -26,22 +26,23 @@ export function ComposerCompactSummary(props: {
const ref = useRef<HTMLDivElement | null>(null);
const presentationMode =
thread.presentationMode ?? agentStatus?.capabilities.presentationMode ?? "terminal";
const presentationCapabilities = agentStatus
? capabilitiesForPresentation(agentStatus.capabilities, presentationMode)
const effectiveAgentStatus = agentStatus
? agentStatusForPresentation(agentStatus, presentationMode, thread.sessionRef)
: undefined;
const presentationCapabilities = effectiveAgentStatus?.capabilities;
const modelLabel =
presentationCapabilities?.models.find((model) => model.id === thread.config.model)?.label ??
thread.config.model;
const effortLabel = thread.config.effort ? formatEffortLabel(thread.config.effort) : undefined;
let controls: ComposerControl[] = [];
if (agentStatus) {
if (effectiveAgentStatus) {
if (presentationMode === "gui") {
controls = buildControls(thread, agentStatus, undefined, () => undefined);
controls = buildControls(thread, effectiveAgentStatus, undefined, () => undefined);
} else {
const buildProviderControls = getComposerControls(thread.agentKind);
if (buildProviderControls) {
controls = buildProviderControls({
capabilities: capabilitiesForPresentation(agentStatus.capabilities, presentationMode),
capabilities: effectiveAgentStatus.capabilities,
config: thread.config,
isDisabled: true,
onConfigChange: () => undefined,
Expand Down Expand Up @@ -87,16 +88,16 @@ export function ComposerCompactSummary(props: {
};
}, [agentStatus, effortLabel, fastEnabled, modeKey, modelLabel, permissionKey]);

if (!agentStatus) return null;
if (!effectiveAgentStatus) return null;

return (
<div ref={ref} className="m-compose-summary" aria-hidden="true">
<ProviderIcon
kind={thread.agentKind}
tone="active"
fallbackLabel={agentStatus.label}
fallbackLabel={effectiveAgentStatus.label}
className="size-3.5 shrink-0"
{...(agentStatus.icon ? { icon: agentStatus.icon } : {})}
{...(effectiveAgentStatus.icon ? { icon: effectiveAgentStatus.icon } : {})}
/>
<span className="m-compose-summary__model">{modelLabel}</span>
{effortLabel ? <span className="m-compose-summary__effort">{effortLabel}</span> : null}
Expand Down
Loading