Merge upstream/master into add-gpt-5-4-xhigh-effort

This commit is contained in:
Kevin Mok 2026-03-08 12:10:59 -05:00
commit 432d7e72fa
227 changed files with 31564 additions and 2543 deletions

View file

@ -30,6 +30,7 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View file

@ -13,6 +13,8 @@ export type {
AdapterEnvironmentTestContext,
AdapterSessionCodec,
AdapterModel,
HireApprovedPayload,
HireApprovedHookResult,
ServerAdapterModule,
TranscriptEntry,
StdoutLineParser,

View file

@ -15,6 +15,14 @@ interface RunningProcess {
graceSec: number;
}
type ChildProcessWithEvents = ChildProcess & {
on(event: "error", listener: (err: Error) => void): ChildProcess;
on(
event: "close",
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
): ChildProcess;
};
export const runningProcesses = new Map<string, RunningProcess>();
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
export const MAX_EXCERPT_BYTES = 32 * 1024;
@ -217,7 +225,7 @@ export async function runChildProcess(
env: mergedEnv,
shell: false,
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
});
}) as ChildProcessWithEvents;
if (opts.stdin != null && child.stdin) {
child.stdin.write(opts.stdin);
@ -244,7 +252,7 @@ export async function runChildProcess(
}, opts.timeoutSec * 1000)
: null;
child.stdout?.on("data", (chunk) => {
child.stdout?.on("data", (chunk: unknown) => {
const text = String(chunk);
stdout = appendWithCap(stdout, text);
logChain = logChain
@ -252,7 +260,7 @@ export async function runChildProcess(
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
});
child.stderr?.on("data", (chunk) => {
child.stderr?.on("data", (chunk: unknown) => {
const text = String(chunk);
stderr = appendWithCap(stderr, text);
logChain = logChain
@ -260,7 +268,7 @@ export async function runChildProcess(
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
});
child.on("error", (err) => {
child.on("error", (err: Error) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
const errno = (err as NodeJS.ErrnoException).code;
@ -272,7 +280,7 @@ export async function runChildProcess(
reject(new Error(msg));
});
child.on("close", (code, signal) => {
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timeout) clearTimeout(timeout);
runningProcesses.delete(runId);
void logChain.finally(() => {

View file

@ -119,6 +119,27 @@ export interface AdapterEnvironmentTestContext {
};
}
/** Payload for the onHireApproved adapter lifecycle hook (e.g. join-request or hire_agent approval). */
export interface HireApprovedPayload {
companyId: string;
agentId: string;
agentName: string;
adapterType: string;
/** "join_request" | "approval" */
source: "join_request" | "approval";
sourceId: string;
approvedAt: string;
/** Canonical operator-facing message for cloud adapters to show the user. */
message: string;
}
/** Result of onHireApproved hook; failures are non-fatal to the approval flow. */
export interface HireApprovedHookResult {
ok: boolean;
error?: string;
detail?: Record<string, unknown>;
}
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
@ -128,6 +149,14 @@ export interface ServerAdapterModule {
models?: AdapterModel[];
listModels?: () => Promise<AdapterModel[]>;
agentConfigurationDoc?: string;
/**
* Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval).
* adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL.
*/
onHireApproved?: (
payload: HireApprovedPayload,
adapterConfig: Record<string, unknown>,
) => Promise<HireApprovedHookResult>;
}
// ---------------------------------------------------------------------------
@ -135,7 +164,7 @@ export interface ServerAdapterModule {
// ---------------------------------------------------------------------------
export type TranscriptEntry =
| { kind: "assistant"; ts: string; text: string }
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
| { kind: "user"; ts: string; text: string }
| { kind: "tool_call"; ts: string; name: string; input: unknown }

View file

@ -45,6 +45,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View file

@ -3,6 +3,8 @@ export const label = "Claude Code (local)";
export const models = [
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
];

View file

@ -45,6 +45,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View file

@ -45,6 +45,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View file

@ -56,7 +56,7 @@ Use when:
- You want structured stream output in run logs via --output-format stream-json
Don't use when:
- You need webhook-style external invocation (use openclaw or http)
- You need webhook-style external invocation (use openclaw_gateway or http)
- You only need one-shot shell commands (use process)
- Cursor Agent CLI is not installed on the machine

View file

@ -2,7 +2,8 @@
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -0,0 +1,72 @@
# OpenClaw Gateway Adapter
This document describes how `@paperclipai/adapter-openclaw-gateway` invokes OpenClaw over the Gateway protocol.
## Transport
This adapter always uses WebSocket gateway transport.
- URL must be `ws://` or `wss://`
- Connect flow follows gateway protocol:
1. receive `connect.challenge`
2. send `req connect` (protocol/client/auth/device payload)
3. send `req agent`
4. wait for completion via `req agent.wait`
5. stream `event agent` frames into Paperclip logs/transcript parsing
## Auth Modes
Gateway credentials can be provided in any of these ways:
- `authToken` / `token` in adapter config
- `headers.x-openclaw-token`
- `headers.x-openclaw-auth` (legacy)
- `password` (shared password mode)
When a token is present and `authorization` header is missing, the adapter derives `Authorization: Bearer <token>`.
## Device Auth
By default the adapter sends a signed `device` payload in `connect` params.
- set `disableDeviceAuth=true` to omit device signing
- set `devicePrivateKeyPem` to pin a stable signing key
- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run
- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once.
## Session Strategy
The adapter supports the same session routing model as HTTP OpenClaw mode:
- `sessionKeyStrategy=issue|fixed|run`
- `sessionKey` is used when strategy is `fixed`
Resolved session key is sent as `agent.sessionKey`.
## Payload Mapping
The agent request is built as:
- required fields:
- `message` (wake text plus optional `payloadTemplate.message`/`payloadTemplate.text` prefix)
- `idempotencyKey` (Paperclip `runId`)
- `sessionKey` (resolved strategy)
- optional additions:
- all `payloadTemplate` fields merged in
- `agentId` from config if set and not already in template
## Timeouts
- `timeoutSec` controls adapter-level request budget
- `waitTimeoutMs` controls `agent.wait.timeoutMs`
If `agent.wait` returns `timeout`, adapter returns `openclaw_gateway_wait_timeout`.
## Log Format
Structured gateway event logs use:
- `[openclaw-gateway] ...` for lifecycle/system logs
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
UI/CLI parsers consume these lines to render transcript updates.

View file

@ -0,0 +1,109 @@
# OpenClaw Gateway Onboarding and Test Plan
## Scope
This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only.
- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching)
- Supported path: `openclaw_gateway` over WebSocket (`ws://` or `wss://`)
## Requirements
1. OpenClaw test image must be stock/clean every run.
2. Onboarding must work from one primary prompt pasted into OpenClaw (optional one follow-up ping allowed).
3. Device auth stays enabled by default; pairing is persisted via `adapterConfig.devicePrivateKeyPem`.
4. Invite/access flow must be secure:
- invite prompt endpoint is board-permission protected
- CEO agent is allowed to invoke the invite prompt endpoint for their own company
5. E2E pass criteria must include the 3 functional task cases.
## Current Product Flow
1. Board/CEO opens company settings.
2. Click `Generate OpenClaw Invite Prompt`.
3. Paste generated prompt into OpenClaw chat.
4. OpenClaw submits invite acceptance with:
- `adapterType: "openclaw_gateway"`
- `agentDefaultsPayload.url: ws://... | wss://...`
- `agentDefaultsPayload.headers["x-openclaw-token"]`
5. Board approves join request.
6. OpenClaw claims API key and installs/uses Paperclip skill.
7. First task run may trigger pairing approval once; after approval, pairing persists via stored device key.
## Technical Contract (Gateway)
`agentDefaultsPayload` minimum:
```json
{
"url": "ws://127.0.0.1:18789",
"headers": { "x-openclaw-token": "<gateway-token>" }
}
```
Recommended fields:
```json
{
"paperclipApiUrl": "http://host.docker.internal:3100",
"waitTimeoutMs": 120000,
"sessionKeyStrategy": "issue",
"role": "operator",
"scopes": ["operator.admin"]
}
```
Security/pairing defaults:
- `disableDeviceAuth`: default false
- `devicePrivateKeyPem`: generated during join if missing
## Codex Automation Workflow
### 0) Reset and boot
```bash
OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker
if [ -d "$OPENCLAW_DOCKER_DIR" ]; then
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true
fi
docker image rm openclaw:local || true
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
```
### 1) Start Paperclip
```bash
pnpm dev --tailscale-auth
curl -fsS http://127.0.0.1:3100/api/health
```
### 2) Invite + join + approval
- create invite prompt via `POST /api/companies/:companyId/openclaw/invite-prompt`
- paste prompt to OpenClaw
- approve join request
- assert created agent:
- `adapterType == openclaw_gateway`
- token header exists and length >= 16
- `devicePrivateKeyPem` exists
### 3) Pairing stabilization
- if first run returns `pairing required`, approve pending device in OpenClaw
- rerun task and confirm success
- assert later runs do not require re-pairing for same agent
### 4) Functional E2E assertions
1. Task assigned to OpenClaw is completed and closed.
2. Task asking OpenClaw to send main-webchat message succeeds (message visible in main chat).
3. In `/new` OpenClaw session, OpenClaw can still create a Paperclip task.
## Manual Smoke Checklist
Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook.
## Regression Gates
Required before merge:
```bash
pnpm -r typecheck
pnpm test:run
pnpm build
```
If full suite is too heavy locally, run at least:
```bash
pnpm --filter @paperclipai/server test:run -- openclaw-gateway
pnpm --filter @paperclipai/server typecheck
pnpm --filter @paperclipai/ui typecheck
pnpm --filter paperclipai typecheck
```

View file

@ -0,0 +1,52 @@
{
"name": "@paperclipai/adapter-openclaw-gateway",
"version": "0.2.7",
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1",
"ws": "^8.19.0"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/ws": "^8.18.1",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,23 @@
import pc from "picocolors";
export function printOpenClawGatewayStreamEvent(raw: string, debug: boolean): void {
const line = raw.trim();
if (!line) return;
if (!debug) {
console.log(line);
return;
}
if (line.startsWith("[openclaw-gateway:event]")) {
console.log(pc.cyan(line));
return;
}
if (line.startsWith("[openclaw-gateway]")) {
console.log(pc.blue(line));
return;
}
console.log(pc.gray(line));
}

View file

@ -0,0 +1 @@
export { printOpenClawGatewayStreamEvent } from "./format-event.js";

View file

@ -0,0 +1,42 @@
export const type = "openclaw_gateway";
export const label = "OpenClaw Gateway";
export const models: { id: string; label: string }[] = [];
export const agentConfigurationDoc = `# openclaw_gateway agent configuration
Adapter: openclaw_gateway
Use when:
- You want Paperclip to invoke OpenClaw over the Gateway WebSocket protocol.
- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*.
Don't use when:
- You only expose OpenClaw HTTP endpoints.
- Your deployment does not permit outbound WebSocket access from the Paperclip server.
Core fields:
- url (string, required): OpenClaw gateway WebSocket URL (ws:// or wss://)
- headers (object, optional): handshake headers; supports x-openclaw-token / x-openclaw-auth
- authToken (string, optional): shared gateway token override
- password (string, optional): gateway shared password, if configured
Gateway connect identity fields:
- clientId (string, optional): gateway client id (default gateway-client)
- clientMode (string, optional): gateway client mode (default backend)
- clientVersion (string, optional): client version string
- role (string, optional): gateway role (default operator)
- scopes (string[] | comma string, optional): gateway scopes (default ["operator.admin"])
- disableDeviceAuth (boolean, optional): disable signed device payload in connect params (default false)
Request behavior fields:
- payloadTemplate (object, optional): additional fields merged into gateway agent params
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text
Session routing fields:
- sessionKeyStrategy (string, optional): issue (default), fixed, or run
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
`;

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,2 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js";

View file

@ -0,0 +1,317 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { randomUUID } from "node:crypto";
import { WebSocket } from "ws";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function nonEmpty(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isLoopbackHost(hostname: string): boolean {
const value = hostname.trim().toLowerCase();
return value === "localhost" || value === "127.0.0.1" || value === "::1";
}
function toStringRecord(value: unknown): Record<string, string> {
const parsed = parseObject(value);
const out: Record<string, string> = {};
for (const [key, entry] of Object.entries(parsed)) {
if (typeof entry === "string") out[key] = entry;
}
return out;
}
function toStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean);
}
if (typeof value === "string") {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
return [];
}
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
return match ? match[1] : null;
}
function tokenFromAuthHeader(rawHeader: string | null): string | null {
if (!rawHeader) return null;
const trimmed = rawHeader.trim();
if (!trimmed) return null;
const match = trimmed.match(/^bearer\s+(.+)$/i);
return match ? nonEmpty(match[1]) : trimmed;
}
function resolveAuthToken(config: Record<string, unknown>, headers: Record<string, string>): string | null {
const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token);
if (explicit) return explicit;
const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token");
if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader);
const authHeader =
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
headerMapGetIgnoreCase(headers, "authorization");
return tokenFromAuthHeader(authHeader);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function rawDataToString(data: unknown): string {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf8");
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
if (Array.isArray(data)) {
return Buffer.concat(
data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))),
).toString("utf8");
}
return String(data ?? "");
}
async function probeGateway(input: {
url: string;
headers: Record<string, string>;
authToken: string | null;
role: string;
scopes: string[];
timeoutMs: number;
}): Promise<"ok" | "challenge_only" | "failed"> {
return await new Promise((resolve) => {
const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 });
const timeout = setTimeout(() => {
try {
ws.close();
} catch {
// ignore
}
resolve("failed");
}, input.timeoutMs);
let completed = false;
const finish = (status: "ok" | "challenge_only" | "failed") => {
if (completed) return;
completed = true;
clearTimeout(timeout);
try {
ws.close();
} catch {
// ignore
}
resolve(status);
};
ws.on("message", (raw) => {
let parsed: unknown;
try {
parsed = JSON.parse(rawDataToString(raw));
} catch {
return;
}
const event = asRecord(parsed);
if (event?.type === "event" && event.event === "connect.challenge") {
const nonce = nonEmpty(asRecord(event.payload)?.nonce);
if (!nonce) {
finish("failed");
return;
}
const connectId = randomUUID();
ws.send(
JSON.stringify({
type: "req",
id: connectId,
method: "connect",
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "gateway-client",
version: "paperclip-probe",
platform: process.platform,
mode: "probe",
},
role: input.role,
scopes: input.scopes,
...(input.authToken
? {
auth: {
token: input.authToken,
},
}
: {}),
},
}),
);
return;
}
if (event?.type === "res") {
if (event.ok === true) {
finish("ok");
} else {
finish("challenge_only");
}
}
});
ws.on("error", () => {
finish("failed");
});
ws.on("close", () => {
if (!completed) finish("failed");
});
});
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const urlValue = asString(config.url, "").trim();
if (!urlValue) {
checks.push({
code: "openclaw_gateway_url_missing",
level: "error",
message: "OpenClaw gateway adapter requires a WebSocket URL.",
hint: "Set adapterConfig.url to ws://host:port (or wss://).",
});
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
let url: URL | null = null;
try {
url = new URL(urlValue);
} catch {
checks.push({
code: "openclaw_gateway_url_invalid",
level: "error",
message: `Invalid URL: ${urlValue}`,
});
}
if (url && url.protocol !== "ws:" && url.protocol !== "wss:") {
checks.push({
code: "openclaw_gateway_url_protocol_invalid",
level: "error",
message: `Unsupported URL protocol: ${url.protocol}`,
hint: "Use ws:// or wss://.",
});
}
if (url) {
checks.push({
code: "openclaw_gateway_url_valid",
level: "info",
message: `Configured gateway URL: ${url.toString()}`,
});
if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) {
checks.push({
code: "openclaw_gateway_plaintext_remote_ws",
level: "warn",
message: "Gateway URL uses plaintext ws:// on a non-loopback host.",
hint: "Prefer wss:// for remote gateways.",
});
}
}
const headers = toStringRecord(config.headers);
const authToken = resolveAuthToken(config, headers);
const password = nonEmpty(config.password);
const role = nonEmpty(config.role) ?? "operator";
const scopes = toStringArray(config.scopes);
if (authToken || password) {
checks.push({
code: "openclaw_gateway_auth_present",
level: "info",
message: "Gateway credentials are configured.",
});
} else {
checks.push({
code: "openclaw_gateway_auth_missing",
level: "warn",
message: "No gateway credentials detected in adapter config.",
hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.",
});
}
if (url && (url.protocol === "ws:" || url.protocol === "wss:")) {
try {
const probeResult = await probeGateway({
url: url.toString(),
headers,
authToken,
role,
scopes: scopes.length > 0 ? scopes : ["operator.admin"],
timeoutMs: 3_000,
});
if (probeResult === "ok") {
checks.push({
code: "openclaw_gateway_probe_ok",
level: "info",
message: "Gateway connect probe succeeded.",
});
} else if (probeResult === "challenge_only") {
checks.push({
code: "openclaw_gateway_probe_challenge_only",
level: "warn",
message: "Gateway challenge was received, but connect probe was rejected.",
hint: "Check gateway credentials, scopes, role, and device-auth requirements.",
});
} else {
checks.push({
code: "openclaw_gateway_probe_failed",
level: "warn",
message: "Gateway probe failed.",
hint: "Verify network reachability and gateway URL from the Paperclip server host.",
});
}
} catch (err) {
checks.push({
code: "openclaw_gateway_probe_error",
level: "warn",
message: err instanceof Error ? err.message : "Gateway probe failed",
});
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View file

@ -0,0 +1,16 @@
export function normalizeOpenClawGatewayStreamLine(rawLine: string): {
stream: "stdout" | "stderr" | null;
line: string;
} {
const trimmed = rawLine.trim();
if (!trimmed) return { stream: null, line: "" };
const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i);
if (!prefixed) {
return { stream: null, line: trimmed };
}
const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout";
const line = (prefixed[2] ?? "").trim();
return { stream, line };
}

View file

@ -0,0 +1,12 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.url) ac.url = v.url;
ac.timeoutSec = 120;
ac.waitTimeoutMs = 120000;
ac.sessionKeyStrategy = "issue";
ac.role = "operator";
ac.scopes = ["operator.admin"];
return ac;
}

View file

@ -0,0 +1,2 @@
export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js";
export { buildOpenClawGatewayConfig } from "./build-config.js";

View file

@ -0,0 +1,75 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { normalizeOpenClawGatewayStreamLine } from "../shared/stream.js";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function parseAgentEventLine(line: string, ts: string): TranscriptEntry[] {
const match = line.match(/^\[openclaw-gateway:event\]\s+run=([^\s]+)\s+stream=([^\s]+)\s+data=(.*)$/s);
if (!match) return [{ kind: "stdout", ts, text: line }];
const stream = asString(match[2]).toLowerCase();
const data = asRecord(safeJsonParse(asString(match[3]).trim()));
if (stream === "assistant") {
const delta = asString(data?.delta);
if (delta.length > 0) {
return [{ kind: "assistant", ts, text: delta, delta: true }];
}
const text = asString(data?.text);
if (text.length > 0) {
return [{ kind: "assistant", ts, text }];
}
return [];
}
if (stream === "error") {
const message = asString(data?.error) || asString(data?.message);
return message ? [{ kind: "stderr", ts, text: message }] : [];
}
if (stream === "lifecycle") {
const phase = asString(data?.phase).toLowerCase();
const message = asString(data?.error) || asString(data?.message);
if ((phase === "error" || phase === "failed" || phase === "cancelled") && message) {
return [{ kind: "stderr", ts, text: message }];
}
}
return [];
}
export function parseOpenClawGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] {
const normalized = normalizeOpenClawGatewayStreamLine(line);
if (normalized.stream === "stderr") {
return [{ kind: "stderr", ts, text: normalized.line }];
}
const trimmed = normalized.line.trim();
if (!trimmed) return [];
if (trimmed.startsWith("[openclaw-gateway:event]")) {
return parseAgentEventLine(trimmed, ts);
}
if (trimmed.startsWith("[openclaw-gateway]")) {
return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw-gateway\]\s*/, "") }];
}
return [{ kind: "stdout", ts, text: normalized.line }];
}

View file

@ -1,57 +0,0 @@
# @paperclipai/adapter-openclaw
## 0.2.7
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.7
## 0.2.6
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.6
## 0.2.5
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.5
## 0.2.4
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.4
## 0.2.3
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.3
## 0.2.2
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.2
## 0.2.1
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/adapter-utils@0.2.1

View file

@ -1,18 +0,0 @@
import pc from "picocolors";
export function printOpenClawStreamEvent(raw: string, debug: boolean): void {
const line = raw.trim();
if (!line) return;
if (!debug) {
console.log(line);
return;
}
if (line.startsWith("[openclaw]")) {
console.log(pc.cyan(line));
return;
}
console.log(pc.gray(line));
}

View file

@ -1 +0,0 @@
export { printOpenClawStreamEvent } from "./format-event.js";

View file

@ -1,27 +0,0 @@
export const type = "openclaw";
export const label = "OpenClaw";
export const models: { id: string; label: string }[] = [];
export const agentConfigurationDoc = `# openclaw agent configuration
Adapter: openclaw
Use when:
- You run an OpenClaw agent remotely and wake it via webhook.
- You want Paperclip heartbeat/task events delivered over HTTP.
Don't use when:
- You need local CLI execution inside Paperclip (use claude_local/codex_local/opencode_local/process).
- The OpenClaw endpoint is not reachable from the Paperclip server.
Core fields:
- url (string, required): OpenClaw webhook endpoint URL
- method (string, optional): HTTP method, default POST
- headers (object, optional): extra HTTP headers for webhook calls
- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth
- payloadTemplate (object, optional): additional JSON payload fields merged into each wake payload
Operational fields:
- timeoutSec (number, optional): request timeout in seconds (default 30)
`;

View file

@ -1,144 +0,0 @@
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { asNumber, asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { parseOpenClawResponse } from "./parse.js";
function nonEmpty(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { config, runId, agent, context, onLog, onMeta } = ctx;
const url = asString(config.url, "").trim();
if (!url) {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: "OpenClaw adapter missing url",
errorCode: "openclaw_url_missing",
};
}
const method = asString(config.method, "POST").trim().toUpperCase() || "POST";
const timeoutSec = Math.max(1, asNumber(config.timeoutSec, 30));
const headersConfig = parseObject(config.headers) as Record<string, unknown>;
const payloadTemplate = parseObject(config.payloadTemplate);
const webhookAuthHeader = nonEmpty(config.webhookAuthHeader);
const headers: Record<string, string> = {
"content-type": "application/json",
};
for (const [key, value] of Object.entries(headersConfig)) {
if (typeof value === "string" && value.trim().length > 0) {
headers[key] = value;
}
}
if (webhookAuthHeader && !headers.authorization && !headers.Authorization) {
headers.authorization = webhookAuthHeader;
}
const wakePayload = {
runId,
agentId: agent.id,
companyId: agent.companyId,
taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId),
issueId: nonEmpty(context.issueId),
wakeReason: nonEmpty(context.wakeReason),
wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId),
approvalId: nonEmpty(context.approvalId),
approvalStatus: nonEmpty(context.approvalStatus),
issueIds: Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [],
};
const body = {
...payloadTemplate,
paperclip: {
...wakePayload,
context,
},
};
if (onMeta) {
await onMeta({
adapterType: "openclaw",
command: "webhook",
commandArgs: [method, url],
context,
});
}
await onLog("stdout", `[openclaw] invoking ${method} ${url}\n`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutSec * 1000);
try {
const response = await fetch(url, {
method,
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
const responseText = await response.text();
if (responseText.trim().length > 0) {
await onLog("stdout", `[openclaw] response (${response.status}) ${responseText.slice(0, 2000)}\n`);
} else {
await onLog("stdout", `[openclaw] response (${response.status}) <empty>\n`);
}
if (!response.ok) {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: `OpenClaw webhook failed with status ${response.status}`,
errorCode: "openclaw_http_error",
resultJson: {
status: response.status,
statusText: response.statusText,
response: parseOpenClawResponse(responseText) ?? responseText,
},
};
}
return {
exitCode: 0,
signal: null,
timedOut: false,
provider: "openclaw",
model: null,
summary: `OpenClaw webhook ${method} ${url}`,
resultJson: {
status: response.status,
statusText: response.statusText,
response: parseOpenClawResponse(responseText) ?? responseText,
},
};
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
await onLog("stderr", `[openclaw] request timed out after ${timeoutSec}s\n`);
return {
exitCode: null,
signal: null,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
errorCode: "timeout",
};
}
const message = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[openclaw] request failed: ${message}\n`);
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: message,
errorCode: "openclaw_request_failed",
};
} finally {
clearTimeout(timeout);
}
}

View file

@ -1,15 +0,0 @@
export function parseOpenClawResponse(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
export function isOpenClawUnknownSessionError(_text: string): boolean {
return false;
}

View file

@ -1,199 +0,0 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function isLoopbackHost(hostname: string): boolean {
const value = hostname.trim().toLowerCase();
return value === "localhost" || value === "127.0.0.1" || value === "::1";
}
function normalizeHostname(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.startsWith("[")) {
const end = trimmed.indexOf("]");
return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase();
}
const firstColon = trimmed.indexOf(":");
if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase();
return trimmed.toLowerCase();
}
function pushDeploymentDiagnostics(
checks: AdapterEnvironmentCheck[],
ctx: AdapterEnvironmentTestContext,
endpointUrl: URL | null,
) {
const mode = ctx.deployment?.mode;
const exposure = ctx.deployment?.exposure;
const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null);
const allowSet = new Set(
(ctx.deployment?.allowedHostnames ?? [])
.map((entry) => normalizeHostname(entry))
.filter((entry): entry is string => Boolean(entry)),
);
const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null;
if (!mode) return;
checks.push({
code: "openclaw_deployment_context",
level: "info",
message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`,
});
if (mode === "authenticated" && exposure === "private") {
if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) {
checks.push({
code: "openclaw_private_bind_hostname_not_allowed",
level: "warn",
message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`,
hint: `Run pnpm paperclipai allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`,
});
}
if (!bindHost || isLoopbackHost(bindHost)) {
checks.push({
code: "openclaw_private_bind_loopback",
level: "warn",
message: "Paperclip is bound to loopback in authenticated/private mode.",
hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.",
});
}
if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) {
checks.push({
code: "openclaw_private_no_allowed_hostnames",
level: "warn",
message: "No explicit allowed hostnames are configured for authenticated/private mode.",
hint: "Set one with pnpm paperclipai allowed-hostname <host> when OpenClaw runs on another machine.",
});
}
}
if (mode === "authenticated" && exposure === "public" && endpointUrl && endpointUrl.protocol !== "https:") {
checks.push({
code: "openclaw_public_http_endpoint",
level: "warn",
message: "OpenClaw endpoint uses HTTP in authenticated/public mode.",
hint: "Prefer HTTPS for public deployments.",
});
}
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const urlValue = asString(config.url, "");
if (!urlValue) {
checks.push({
code: "openclaw_url_missing",
level: "error",
message: "OpenClaw adapter requires a webhook URL.",
hint: "Set adapterConfig.url to your OpenClaw webhook endpoint.",
});
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
let url: URL | null = null;
try {
url = new URL(urlValue);
} catch {
checks.push({
code: "openclaw_url_invalid",
level: "error",
message: `Invalid URL: ${urlValue}`,
});
}
if (url && url.protocol !== "http:" && url.protocol !== "https:") {
checks.push({
code: "openclaw_url_protocol_invalid",
level: "error",
message: `Unsupported URL protocol: ${url.protocol}`,
hint: "Use an http:// or https:// endpoint.",
});
}
if (url) {
checks.push({
code: "openclaw_url_valid",
level: "info",
message: `Configured endpoint: ${url.toString()}`,
});
if (isLoopbackHost(url.hostname)) {
checks.push({
code: "openclaw_loopback_endpoint",
level: "warn",
message: "Endpoint uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.",
hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).",
});
}
}
pushDeploymentDiagnostics(checks, ctx, url);
const method = asString(config.method, "POST").trim().toUpperCase() || "POST";
checks.push({
code: "openclaw_method_configured",
level: "info",
message: `Configured method: ${method}`,
});
if (url && (url.protocol === "http:" || url.protocol === "https:")) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(url, { method: "HEAD", signal: controller.signal });
if (!response.ok && response.status !== 405 && response.status !== 501) {
checks.push({
code: "openclaw_endpoint_probe_unexpected_status",
level: "warn",
message: `Endpoint probe returned HTTP ${response.status}.`,
hint: "Verify OpenClaw webhook reachability and auth/network settings.",
});
} else {
checks.push({
code: "openclaw_endpoint_probe_ok",
level: "info",
message: "Endpoint responded to a HEAD probe.",
});
}
} catch (err) {
checks.push({
code: "openclaw_endpoint_probe_failed",
level: "warn",
message: err instanceof Error ? err.message : "Endpoint probe failed",
hint: "This may be expected in restricted networks; validate from the Paperclip server host.",
});
} finally {
clearTimeout(timeout);
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View file

@ -1,9 +0,0 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
export function buildOpenClawConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.url) ac.url = v.url;
ac.method = "POST";
ac.timeoutSec = 30;
return ac;
}

View file

@ -1,2 +0,0 @@
export { parseOpenClawStdoutLine } from "./parse-stdout.js";
export { buildOpenClawConfig } from "./build-config.js";

View file

@ -1,5 +0,0 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] {
return [{ kind: "stdout", ts, text: line }];
}

View file

@ -4,4 +4,4 @@
### Patch Changes
- Added initial `opencode_local` adapter package for local OpenCode execution
- Add local OpenCode adapter package with server/UI/CLI modules.

View file

@ -45,6 +45,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View file

@ -1,5 +1,13 @@
import pc from "picocolors";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@ -13,42 +21,21 @@ function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function printToolEvent(part: Record<string, unknown>): void {
const tool = asString(part.tool, "tool");
const callId = asString(part.callID, asString(part.id, ""));
const state = asRecord(part.state);
const status = asString(state?.status);
const input = state?.input;
const output = asString(state?.output).replace(/\s+$/, "");
const metadata = asRecord(state?.metadata);
const exit = asNumber(metadata?.exit, NaN);
const isError =
status === "failed" ||
status === "error" ||
status === "cancelled" ||
(Number.isFinite(exit) && exit !== 0);
console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`));
if (input !== undefined) {
try {
console.log(pc.gray(JSON.stringify(input, null, 2)));
} catch {
console.log(pc.gray(String(input)));
}
}
if (status || output) {
const summary = [
"tool_result",
status ? `status=${status}` : "",
Number.isFinite(exit) ? `exit=${exit}` : "",
]
.filter(Boolean)
.join(" ");
console.log((isError ? pc.red : pc.cyan)(summary));
if (output) {
console.log((isError ? pc.red : pc.gray)(output));
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = asRecord(value);
if (!rec) return "";
const data = asRecord(rec.data);
const message =
asString(rec.message) ||
asString(data?.message) ||
asString(rec.name) ||
"";
if (message) return message;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
@ -56,10 +43,8 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (!line) return;
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(line) as Record<string, unknown>;
} catch {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
console.log(line);
return;
}
@ -74,18 +59,41 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
if (type === "text") {
const part = asRecord(parsed.part);
const text = asString(part?.text);
const text = asString(part?.text).trim();
if (text) console.log(pc.green(`assistant: ${text}`));
return;
}
if (type === "reasoning") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (text) console.log(pc.gray(`thinking: ${text}`));
return;
}
if (type === "tool_use") {
const part = asRecord(parsed.part);
if (part) {
printToolEvent(part);
} else {
console.log(pc.yellow("tool_use"));
const tool = asString(part?.tool, "tool");
const callID = asString(part?.callID);
const state = asRecord(part?.state);
const status = asString(state?.status);
const isError = status === "error";
const metadata = asRecord(state?.metadata);
console.log(pc.yellow(`tool_call: ${tool}${callID ? ` (${callID})` : ""}`));
if (status) {
const metaParts = [`status=${status}`];
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined && value !== null) metaParts.push(`${key}=${value}`);
}
}
console.log((isError ? pc.red : pc.gray)(`tool_result ${metaParts.join(" ")}`));
}
const output = (asString(state?.output) || asString(state?.error)).trim();
if (output) console.log((isError ? pc.red : pc.gray)(output));
return;
}
@ -93,20 +101,19 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
const part = asRecord(parsed.part);
const tokens = asRecord(part?.tokens);
const cache = asRecord(tokens?.cache);
const reason = asString(part?.reason, "step_finish");
const input = asNumber(tokens?.input);
const output = asNumber(tokens?.output);
const cached = asNumber(cache?.read);
const cost = asNumber(part?.cost);
const input = asNumber(tokens?.input, 0);
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
const cached = asNumber(cache?.read, 0);
const cost = asNumber(part?.cost, 0);
const reason = asString(part?.reason, "step");
console.log(pc.blue(`step finished: reason=${reason}`));
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
return;
}
if (type === "error") {
const part = asRecord(parsed.part);
const message = asString(parsed.message) || asString(part?.message) || line;
console.log(pc.red(`error: ${message}`));
const message = errorText(parsed.error ?? parsed.message);
if (message) console.log(pc.red(`error: ${message}`));
return;
}

View file

@ -1,8 +1,9 @@
export const type = "opencode_local";
export const label = "OpenCode (local)";
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
export const models = [
export const models: Array<{ id: string; label: string }> = [
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
{ id: "openai/gpt-5.4", label: "openai/gpt-5.4" },
{ id: "openai/gpt-5.2", label: "openai/gpt-5.2" },
@ -20,14 +21,14 @@ Use when:
- You want OpenCode session resume across heartbeats via --session
Don't use when:
- You need webhook-style external invocation (use openclaw or http)
- You need webhook-style external invocation (use openclaw_gateway or http)
- You only need one-shot shell commands (use process)
- OpenCode CLI is not installed on the machine
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- model (string, optional): OpenCode model id in provider/model format (for example openai/gpt-5.2-codex)
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
- variant (string, optional): provider-specific reasoning/profile variant passed as --variant (for example minimal|low|medium|high|xhigh|max)
- promptTemplate (string, optional): run prompt template
- command (string, optional): defaults to "opencode"
@ -39,7 +40,9 @@ Operational fields:
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- OpenCode supports multiple providers and models. Use \
\`opencode models\` to list available options in provider/model format.
- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents.
- Runs are executed with: opencode run --format json ...
- Prompts are passed as the final positional message argument.
- Sessions are resumed with --session when stored session cwd matches current cwd.
`;

View file

@ -16,8 +16,8 @@ import {
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
@ -34,81 +34,11 @@ function firstNonEmptyLine(text: string): string {
);
}
function getEffectiveEnvValue(envOverrides: Record<string, string>, key: string): string {
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
const raw = envOverrides[key];
return typeof raw === "string" ? raw : "";
}
const raw = process.env[key];
return typeof raw === "string" ? raw : "";
}
function hasEffectiveEnvValue(envOverrides: Record<string, string>, key: string): boolean {
return getEffectiveEnvValue(envOverrides, key).trim().length > 0;
}
function resolveOpenCodeBillingType(env: Record<string, string>): "api" | "subscription" {
return hasEffectiveEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
}
function resolveProviderFromModel(model: string): string | null {
function parseModelProvider(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
if (!trimmed) return null;
const slash = trimmed.indexOf("/");
if (slash <= 0) return null;
return trimmed.slice(0, slash).toLowerCase();
}
function isProviderModelNotFoundFailure(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`;
return /ProviderModelNotFoundError|provider model not found/i.test(haystack);
}
type ProviderModelNotFoundDetails = {
providerId: string | null;
modelId: string | null;
suggestions: string[];
};
function parseProviderModelNotFoundDetails(
stdout: string,
stderr: string,
): ProviderModelNotFoundDetails | null {
if (!isProviderModelNotFoundFailure(stdout, stderr)) return null;
const haystack = `${stdout}\n${stderr}`;
const providerMatch = haystack.match(/providerID:\s*"([^"]+)"/i);
const modelMatch = haystack.match(/modelID:\s*"([^"]+)"/i);
const suggestionsMatch = haystack.match(/suggestions:\s*\[([^\]]*)\]/i);
const suggestions = suggestionsMatch
? Array.from(
suggestionsMatch[1].matchAll(/"([^"]+)"/g),
(match) => match[1].trim(),
).filter((value) => value.length > 0)
: [];
return {
providerId: providerMatch?.[1]?.trim().toLowerCase() || null,
modelId: modelMatch?.[1]?.trim() || null,
suggestions,
};
}
function formatModelNotFoundError(
model: string,
providerFromModel: string | null,
details: ProviderModelNotFoundDetails | null,
): string {
const provider = details?.providerId || providerFromModel || "unknown";
const missingModel = details?.modelId || model;
const suggestions = details?.suggestions ?? [];
const suggestionText =
suggestions.length > 0 ? ` Suggested models: ${suggestions.map((value) => `\`${value}\``).join(", ")}.` : "";
return (
`OpenCode model \`${missingModel}\` is unavailable for provider \`${provider}\`.` +
` Run \`opencode models ${provider}\` and set adapterConfig.model to a supported value.` +
suggestionText
);
if (!trimmed.includes("/")) return null;
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
}
function claudeSkillsHome(): string {
@ -160,8 +90,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
);
const command = asString(config.command, "opencode");
const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL);
const variant = asString(config.variant, asString(config.effort, ""));
const model = asString(config.model, "").trim();
const variant = asString(config.variant, "").trim();
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
@ -209,52 +139,39 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
if (wakeTaskId) {
env.PAPERCLIP_TASK_ID = wakeTaskId;
}
if (wakeReason) {
env.PAPERCLIP_WAKE_REASON = wakeReason;
}
if (wakeCommentId) {
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
}
if (approvalId) {
env.PAPERCLIP_APPROVAL_ID = approvalId;
}
if (approvalStatus) {
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
}
if (linkedIssueIds.length > 0) {
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (effectiveWorkspaceCwd) {
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
}
if (workspaceSource) {
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
}
if (workspaceId) {
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
}
if (workspaceRepoUrl) {
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
}
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const billingType = resolveOpenCodeBillingType(env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
await ensureCommandResolvable(command, cwd, runtimeEnv);
await ensureOpenCodeModelConfiguredAndAvailable({
model,
command,
cwd,
env: runtimeEnv,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
@ -278,37 +195,41 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
const resolvedInstructionsFilePath = instructionsFilePath
? path.resolve(cwd, instructionsFilePath)
: "";
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
let instructionsPrefix = "";
if (instructionsFilePath) {
if (resolvedInstructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
instructionsPrefix =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
);
}
}
const commandNotes = (() => {
if (!instructionsFilePath) return [] as string[];
if (!resolvedInstructionsFilePath) return [] as string[];
if (instructionsPrefix.length > 0) {
return [
`Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
];
}
return [
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
];
})();
@ -329,7 +250,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (model) args.push("--model", model);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
args.push(prompt);
return args;
};
@ -341,10 +261,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
command,
cwd,
commandNotes,
commandArgs: args.map((value, idx) => {
if (idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
return value;
}),
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(env),
prompt,
context,
@ -353,29 +270,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const proc = await runChildProcess(runId, command, args, {
cwd,
env,
env: runtimeEnv,
stdin: prompt,
timeoutSec,
graceSec,
onLog,
});
return {
proc,
rawStderr: proc.stderr,
parsed: parseOpenCodeJsonl(proc.stdout),
};
};
const providerFromModel = resolveProviderFromModel(model);
const toResult = (
attempt: {
proc: {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
};
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
rawStderr: string;
parsed: ReturnType<typeof parseOpenCodeJsonl>;
},
clearSessionOnMissingSession = false,
@ -390,7 +301,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
}
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
const resolvedSessionId =
attempt.parsed.sessionId ??
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
@ -400,50 +313,54 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>)
: null;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const modelNotFound = parseProviderModelNotFoundDetails(attempt.proc.stdout, attempt.proc.stderr);
const fallbackErrorMessage = modelNotFound
? formatModelNotFoundError(model, providerFromModel, modelNotFound)
: parsedError ||
stderrLine ||
`OpenCode exited with code ${attempt.proc.exitCode ?? -1}`;
const rawExitCode = attempt.proc.exitCode;
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
const fallbackErrorMessage =
parsedError ||
stderrLine ||
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
const modelId = model || null;
return {
exitCode: attempt.proc.exitCode,
exitCode: synthesizedExitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage:
(attempt.proc.exitCode ?? 0) === 0
? null
: fallbackErrorMessage,
usage: attempt.parsed.usage,
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
usage: {
inputTokens: attempt.parsed.usage.inputTokens,
outputTokens: attempt.parsed.usage.outputTokens,
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
},
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: providerFromModel,
model,
billingType,
provider: parseModelProvider(modelId),
model: modelId,
billingType: "unknown",
costUsd: attempt.parsed.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
};
};
const initial = await runAttempt(sessionId);
const initialFailed =
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
initialFailed &&
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) {
await onLog(
"stderr",
`[paperclip] OpenCode resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true);

View file

@ -1,6 +1,3 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {
@ -62,3 +59,13 @@ export const sessionCodec: AdapterSessionCodec = {
);
},
};
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export {
listOpenCodeModels,
discoverOpenCodeModels,
ensureOpenCodeModelConfiguredAndAvailable,
resetOpenCodeModelsCacheForTests,
} from "./models.js";
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";

View file

@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import {
ensureOpenCodeModelConfiguredAndAvailable,
listOpenCodeModels,
resetOpenCodeModelsCacheForTests,
} from "./models.js";
describe("openCode models", () => {
afterEach(() => {
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
resetOpenCodeModelsCacheForTests();
});
it("returns an empty list when discovery command is unavailable", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
await expect(listOpenCodeModels()).resolves.toEqual([]);
});
it("rejects when model is missing", async () => {
await expect(
ensureOpenCodeModelConfiguredAndAvailable({ model: "" }),
).rejects.toThrow("OpenCode requires `adapterConfig.model`");
});
it("rejects when discovery cannot run for configured model", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
await expect(
ensureOpenCodeModelConfiguredAndAvailable({
model: "openai/gpt-5",
}),
).rejects.toThrow("Failed to start command");
});
});

View file

@ -0,0 +1,195 @@
import { createHash } from "node:crypto";
import type { AdapterModel } from "@paperclipai/adapter-utils";
import {
asString,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
const MODELS_CACHE_TTL_MS = 60_000;
function resolveOpenCodeCommand(input: unknown): string {
const envOverride =
typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" &&
process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0
? process.env.PAPERCLIP_OPENCODE_COMMAND.trim()
: "opencode";
return asString(input, envOverride);
}
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]);
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const deduped: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push({ id, label: model.label.trim() || id });
}
return deduped;
}
function sortModels(models: AdapterModel[]): AdapterModel[] {
return [...models].sort((a, b) =>
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
);
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function parseModelsOutput(stdout: string): AdapterModel[] {
const parsed: AdapterModel[] = [];
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const firstToken = line.split(/\s+/)[0]?.trim() ?? "";
if (!firstToken.includes("/")) continue;
const provider = firstToken.slice(0, firstToken.indexOf("/")).trim();
const model = firstToken.slice(firstToken.indexOf("/") + 1).trim();
if (!provider || !model) continue;
parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` });
}
return dedupeModels(parsed);
}
function normalizeEnv(input: unknown): Record<string, string> {
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envInput)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
function isVolatileEnvKey(key: string): boolean {
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function hashValue(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
const envKey = Object.entries(env)
.filter(([key]) => !isVolatileEnvKey(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${hashValue(value)}`)
.join("\n");
return `${command}\n${cwd}\n${envKey}`;
}
function pruneExpiredDiscoveryCache(now: number) {
for (const [key, value] of discoveryCache.entries()) {
if (value.expiresAt <= now) discoveryCache.delete(key);
}
}
export async function discoverOpenCodeModels(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = resolveOpenCodeCommand(input.command);
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
const result = await runChildProcess(
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
["models"],
{
cwd,
env: runtimeEnv,
timeoutSec: 20,
graceSec: 3,
onLog: async () => {},
},
);
if (result.timedOut) {
throw new Error("`opencode models` timed out.");
}
if ((result.exitCode ?? 1) !== 0) {
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
}
return sortModels(parseModelsOutput(result.stdout));
}
export async function discoverOpenCodeModelsCached(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = resolveOpenCodeCommand(input.command);
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const key = discoveryCacheKey(command, cwd, env);
const now = Date.now();
pruneExpiredDiscoveryCache(now);
const cached = discoveryCache.get(key);
if (cached && cached.expiresAt > now) return cached.models;
const models = await discoverOpenCodeModels({ command, cwd, env });
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
return models;
}
export async function ensureOpenCodeModelConfiguredAndAvailable(input: {
model?: unknown;
command?: unknown;
cwd?: unknown;
env?: unknown;
}): Promise<AdapterModel[]> {
const model = asString(input.model, "").trim();
if (!model) {
throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
}
const models = await discoverOpenCodeModelsCached({
command: input.command,
cwd: input.cwd,
env: input.env,
});
if (models.length === 0) {
throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth.");
}
if (!models.some((entry) => entry.id === model)) {
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
throw new Error(
`Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
);
}
return models;
}
export async function listOpenCodeModels(): Promise<AdapterModel[]> {
try {
return await discoverOpenCodeModelsCached();
} catch {
return [];
}
}
export function resetOpenCodeModelsCacheForTests() {
discoveryCache.clear();
}

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
describe("parseOpenCodeJsonl", () => {
it("parses assistant text, usage, cost, and errors", () => {
const stdout = [
JSON.stringify({
type: "text",
sessionID: "session_123",
part: { text: "Hello from OpenCode" },
}),
JSON.stringify({
type: "step_finish",
sessionID: "session_123",
part: {
reason: "done",
cost: 0.0025,
tokens: {
input: 120,
output: 40,
reasoning: 10,
cache: { read: 20, write: 0 },
},
},
}),
JSON.stringify({
type: "error",
sessionID: "session_123",
error: { message: "model unavailable" },
}),
].join("\n");
const parsed = parseOpenCodeJsonl(stdout);
expect(parsed.sessionId).toBe("session_123");
expect(parsed.summary).toBe("Hello from OpenCode");
expect(parsed.usage).toEqual({
inputTokens: 120,
cachedInputTokens: 20,
outputTokens: 50,
});
expect(parsed.costUsd).toBeCloseTo(0.0025, 6);
expect(parsed.errorMessage).toContain("model unavailable");
});
it("detects unknown session errors", () => {
expect(isOpenCodeUnknownSessionError("Session not found: s_123", "")).toBe(true);
expect(isOpenCodeUnknownSessionError("", "unknown session id")).toBe(true);
expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false);
});
});

View file

@ -1,10 +1,17 @@
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
function asErrorText(value: unknown): string {
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = parseObject(value);
const message = asString(rec.message, "") || asString(rec.error, "") || asString(rec.code, "");
const message = asString(rec.message, "").trim();
if (message) return message;
const data = parseObject(rec.data);
const nestedMessage = asString(data.message, "").trim();
if (nestedMessage) return nestedMessage;
const name = asString(rec.name, "").trim();
if (name) return name;
const code = asString(rec.code, "").trim();
if (code) return code;
try {
return JSON.stringify(rec);
} catch {
@ -15,13 +22,13 @@ function asErrorText(value: unknown): string {
export function parseOpenCodeJsonl(stdout: string) {
let sessionId: string | null = null;
const messages: string[] = [];
let errorMessage: string | null = null;
let totalCostUsd = 0;
const errors: string[] = [];
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
};
let costUsd = 0;
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
@ -30,8 +37,8 @@ export function parseOpenCodeJsonl(stdout: string) {
const event = parseJson(line);
if (!event) continue;
const foundSession = asString(event.sessionID, "").trim();
if (foundSession) sessionId = foundSession;
const currentSessionId = asString(event.sessionID, "").trim();
if (currentSessionId) sessionId = currentSessionId;
const type = asString(event.type, "");
@ -48,15 +55,25 @@ export function parseOpenCodeJsonl(stdout: string) {
const cache = parseObject(tokens.cache);
usage.inputTokens += asNumber(tokens.input, 0);
usage.cachedInputTokens += asNumber(cache.read, 0);
usage.outputTokens += asNumber(tokens.output, 0);
totalCostUsd += asNumber(part.cost, 0);
usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
costUsd += asNumber(part.cost, 0);
continue;
}
if (type === "tool_use") {
const part = parseObject(event.part);
const state = parseObject(part.state);
if (asString(state.status, "") === "error") {
const text = asString(state.error, "").trim();
if (text) errors.push(text);
}
continue;
}
if (type === "error") {
const part = parseObject(event.part);
const msg = asErrorText(event.message ?? part.message ?? event.error ?? part.error).trim();
if (msg) errorMessage = msg;
const text = errorText(event.error ?? event.message).trim();
if (text) errors.push(text);
continue;
}
}
@ -64,8 +81,8 @@ export function parseOpenCodeJsonl(stdout: string) {
sessionId,
summary: messages.join("\n\n").trim(),
usage,
costUsd: totalCostUsd > 0 ? totalCostUsd : null,
errorMessage,
costUsd,
errorMessage: errors.length > 0 ? errors.join("\n") : null,
};
}
@ -76,7 +93,7 @@ export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): b
.filter(Boolean)
.join("\n");
return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror/i.test(
return /unknown\s+session|session\b.*\bnot\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test(
haystack,
);
}

View file

@ -12,8 +12,7 @@ import {
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import path from "node:path";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
@ -22,19 +21,6 @@ function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentT
return "pass";
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function getEffectiveEnvValue(envOverrides: Record<string, string>, key: string): string {
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
const raw = envOverrides[key];
return typeof raw === "string" ? raw : "";
}
const raw = process.env[key];
return typeof raw === "string" ? raw : "";
}
function firstNonEmptyLine(text: string): string {
return (
text
@ -44,22 +30,25 @@ function firstNonEmptyLine(text: string): string {
);
}
function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 1)}` : clean;
return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean;
}
function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
const OPENCODE_AUTH_REQUIRED_RE =
/(?:not\s+authenticated|authentication\s+required|unauthorized|forbidden|api(?:[_\s-]?key)?(?:\s+is)?\s+required|missing\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|provider\s+credentials|login\s+required)/i;
const OPENCODE_MODEL_NOT_FOUND_RE = /ProviderModelNotFoundError|provider\s+model\s+not\s+found/i;
/(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/i;
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
@ -70,7 +59,7 @@ export async function testEnvironment(
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
checks.push({
code: "opencode_cwd_valid",
level: "info",
@ -90,100 +79,186 @@ export async function testEnvironment(
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "opencode_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "opencode_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
const configDefinesOpenAiKey = Object.prototype.hasOwnProperty.call(env, "OPENAI_API_KEY");
const effectiveOpenAiKey = getEffectiveEnvValue(env, "OPENAI_API_KEY");
if (isNonEmpty(effectiveOpenAiKey)) {
const source = configDefinesOpenAiKey ? "adapter config env" : "server environment";
checks.push({
code: "opencode_openai_api_key_present",
level: "info",
message: "OPENAI_API_KEY is set for OpenCode authentication.",
detail: `Detected in ${source}.`,
});
} else {
const openaiKeyOverride = "OPENAI_API_KEY" in envConfig ? asString(envConfig.OPENAI_API_KEY, "") : null;
if (openaiKeyOverride !== null && openaiKeyOverride.trim() === "") {
checks.push({
code: "opencode_openai_api_key_missing",
level: "warn",
message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.",
hint: configDefinesOpenAiKey
? "adapterConfig.env defines OPENAI_API_KEY but it is empty. Set a non-empty value or remove the override."
: "Set OPENAI_API_KEY in adapter env/shell, or authenticate with `opencode auth login`.",
message: "OPENAI_API_KEY override is empty.",
hint: "The OPENAI_API_KEY override is empty. Set a valid key or remove the override.",
});
}
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
if (cwdInvalid) {
checks.push({
code: "opencode_command_skipped",
level: "warn",
message: "Skipped command check because working directory validation failed.",
detail: command,
});
} else {
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "opencode_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "opencode_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
}
const canRunProbe =
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
if (canRunProbe) {
if (!commandLooksLike(command, "opencode")) {
checks.push({
code: "opencode_hello_probe_skipped_custom_command",
level: "info",
message: "Skipped hello probe because command is not `opencode`.",
detail: command,
hint: "Use the `opencode` CLI command to run the automatic installation and auth probe.",
let modelValidationPassed = false;
const configuredModel = asString(config.model, "").trim();
if (canRunProbe && configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
checks.push({
code: "opencode_models_discovered",
level: "info",
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
});
} else {
checks.push({
code: "opencode_models_empty",
level: "error",
message: "OpenCode returned no models.",
hint: "Run `opencode models` and verify provider authentication.",
});
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (/ProviderModelNotFoundError/i.test(errMsg)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
detail: errMsg,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else {
checks.push({
code: "opencode_models_discovery_failed",
level: "error",
message: errMsg || "OpenCode model discovery failed.",
hint: "Run `opencode models` manually to verify provider auth and config.",
});
}
}
} else if (canRunProbe && !configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
checks.push({
code: "opencode_models_discovered",
level: "info",
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
});
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (/ProviderModelNotFoundError/i.test(errMsg)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
detail: errMsg,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else {
checks.push({
code: "opencode_models_discovery_failed",
level: "warn",
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
hint: "Run `opencode models` manually to verify provider auth and config.",
});
}
}
}
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
if (!configuredModel && !modelUnavailable) {
// No model configured skip model requirement if no model-related checks exist
} else if (configuredModel && canRunProbe) {
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: configuredModel,
command,
cwd,
env: runtimeEnv,
});
} else {
const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL).trim();
const variant = asString(config.variant, asString(config.effort, "")).trim();
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
checks.push({
code: "opencode_model_configured",
level: "info",
message: `Configured model: ${configuredModel}`,
});
modelValidationPassed = true;
} catch (err) {
checks.push({
code: "opencode_model_invalid",
level: "error",
message: err instanceof Error ? err.message : "Configured model is unavailable.",
hint: "Run `opencode models` and choose a currently available provider/model ID.",
});
}
}
const args = ["run", "--format", "json"];
if (model) args.push("--model", model);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("Respond with hello.");
if (canRunProbe && modelValidationPassed) {
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const variant = asString(config.variant, "").trim();
const probeModel = configuredModel;
const args = ["run", "--format", "json"];
args.push("--model", probeModel);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
try {
const probe = await runChildProcess(
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{
cwd,
env,
timeoutSec: 45,
env: runtimeEnv,
timeoutSec: 60,
graceSec: 5,
stdin: "Respond with hello.",
onLog: async () => {},
},
);
const parsed = parseOpenCodeJsonl(probe.stdout);
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
const modelNotFound = OPENCODE_MODEL_NOT_FOUND_RE.test(authEvidence);
const modelProvider = (() => {
const slash = model.indexOf("/");
if (slash <= 0) return "openai";
return model.slice(0, slash).toLowerCase();
})();
if (probe.timedOut) {
checks.push({
code: "opencode_hello_probe_timed_out",
level: "warn",
message: "OpenCode hello probe timed out.",
hint: "Retry the probe. If this persists, verify `opencode run --format json \"Respond with hello\"` manually.",
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
});
} else if ((probe.exitCode ?? 1) === 0) {
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
const summary = parsed.summary.trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
@ -196,24 +271,24 @@ export async function testEnvironment(
...(hasHello
? {}
: {
hint: "Try `opencode run --format json \"Respond with hello\"` manually to inspect full output.",
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
}),
});
} else if (modelNotFound) {
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: `OpenCode could not run model \`${model}\`.`,
message: "The configured model was not found by the provider.",
...(detail ? { detail } : {}),
hint: `Run \`opencode models ${modelProvider}\` and set adapterConfig.model to one of the available models.`,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_auth_required",
level: "warn",
message: "OpenCode CLI is installed, but authentication is not ready.",
message: "OpenCode is installed, but provider authentication is not ready.",
...(detail ? { detail } : {}),
hint: "Configure OPENAI_API_KEY in adapter env/shell, then retry the probe.",
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
});
} else {
checks.push({
@ -221,9 +296,17 @@ export async function testEnvironment(
level: "error",
message: "OpenCode hello probe failed.",
...(detail ? { detail } : {}),
hint: "Run `opencode run --format json \"Respond with hello\"` manually in this working directory to debug.",
hint: "Run `opencode run --format json` manually in this working directory to debug.",
});
}
} catch (err) {
checks.push({
code: "opencode_hello_probe_failed",
level: "error",
message: "OpenCode hello probe failed.",
detail: err instanceof Error ? err.message : String(err),
hint: "Run `opencode run --format json` manually in this working directory to debug.",
});
}
}

View file

@ -1,5 +1,4 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
function parseCommaArgs(value: string): string[] {
return value
@ -56,10 +55,12 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
ac.model = v.model || DEFAULT_OPENCODE_LOCAL_MODEL;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
ac.timeoutSec = 0;
ac.graceSec = 15;
ac.graceSec = 20;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {

View file

@ -21,26 +21,66 @@ function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function stringifyUnknown(value: unknown): string {
function errorText(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
const rec = asRecord(value);
if (!rec) return "";
const data = asRecord(rec.data);
const msg =
asString(rec.message) ||
asString(data?.message) ||
asString(rec.name) ||
"";
if (msg) return msg;
try {
return JSON.stringify(value, null, 2);
return JSON.stringify(rec);
} catch {
return String(value);
return "";
}
}
function isJsonLike(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return false;
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
const part = asRecord(parsed.part);
if (!part) return [{ kind: "system", ts, text: "tool event" }];
const toolName = asString(part.tool, "tool");
const state = asRecord(part.state);
const input = state?.input ?? {};
const callEntry: TranscriptEntry = {
kind: "tool_call",
ts,
name: toolName,
input,
};
const status = asString(state?.status);
if (status !== "completed" && status !== "error") return [callEntry];
const rawOutput =
asString(state?.output) ||
asString(state?.error) ||
asString(part.title) ||
`${toolName} ${status}`;
const metadata = asRecord(state?.metadata);
const headerParts: string[] = [`status: ${status}`];
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined && value !== null) headerParts.push(`${key}: ${value}`);
}
}
const content = `${headerParts.join("\n")}\n\n${rawOutput}`.trim();
return [
callEntry,
{
kind: "tool_result",
ts,
toolUseId: asString(part.callID) || asString(part.id, toolName),
content,
isError: status === "error",
},
];
}
export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEntry[] {
@ -51,6 +91,24 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt
const type = asString(parsed.type);
if (type === "text") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (!text) return [];
return [{ kind: "assistant", ts, text }];
}
if (type === "reasoning") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (!text) return [];
return [{ kind: "thinking", ts, text }];
}
if (type === "tool_use") {
return parseToolUse(parsed, ts);
}
if (type === "step_start") {
const sessionId = asString(parsed.sessionID);
return [
@ -62,93 +120,31 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt
];
}
if (type === "text") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (!text) return [];
return [{ kind: "assistant", ts, text }];
}
if (type === "tool_use") {
const part = asRecord(parsed.part);
const toolUseId = asString(part?.callID, asString(part?.id, "tool_use"));
const toolName = asString(part?.tool, "tool");
const state = asRecord(part?.state);
const input = state?.input ?? {};
const output = asString(state?.output).trim();
const status = asString(state?.status).trim();
const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN);
const isError =
status === "failed" ||
status === "error" ||
status === "cancelled" ||
(Number.isFinite(exitCode) && exitCode !== 0);
const entries: TranscriptEntry[] = [
{
kind: "tool_call",
ts,
name: toolName,
input,
},
];
if (status || output) {
const lines: string[] = [];
if (status) lines.push(`status: ${status}`);
if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`);
if (output) {
if (lines.length > 0) lines.push("");
if (isJsonLike(output)) {
try {
lines.push(JSON.stringify(JSON.parse(output), null, 2));
} catch {
lines.push(output);
}
} else {
lines.push(output);
}
}
entries.push({
kind: "tool_result",
ts,
toolUseId,
content: lines.join("\n").trim() || "tool completed",
isError,
});
}
return entries;
}
if (type === "step_finish") {
const part = asRecord(parsed.part);
const tokens = asRecord(part?.tokens);
const cache = asRecord(tokens?.cache);
const reason = asString(part?.reason);
const reason = asString(part?.reason, "step");
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
return [
{
kind: "result",
ts,
text: reason,
inputTokens: asNumber(tokens?.input),
outputTokens: asNumber(tokens?.output),
cachedTokens: asNumber(cache?.read),
costUsd: asNumber(part?.cost),
subtype: reason || "step_finish",
isError: reason === "error" || reason === "failed",
inputTokens: asNumber(tokens?.input, 0),
outputTokens: output,
cachedTokens: asNumber(cache?.read, 0),
costUsd: asNumber(part?.cost, 0),
subtype: reason,
isError: false,
errors: [],
},
];
}
if (type === "error") {
const message =
asString(parsed.message) ||
asString(asRecord(parsed.part)?.message) ||
stringifyUnknown(parsed.error ?? asRecord(parsed.part)?.error) ||
line;
return [{ kind: "stderr", ts, text: message }];
const text = errorText(parsed.error ?? parsed.message);
return [{ kind: "stderr", ts, text: text || line }];
}
return [{ kind: "stdout", ts, text: line }];

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});

View file

@ -1,6 +1,6 @@
{
"name": "@paperclipai/adapter-openclaw",
"version": "0.2.7",
"name": "@paperclipai/adapter-pi-local",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts",
@ -44,6 +44,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,107 @@
import pc from "picocolors";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!)
.join("");
}
export function printPiStreamEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (!line) return;
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
console.log(line);
return;
}
const type = asString(parsed.type);
if (type === "agent_start") {
console.log(pc.blue("Pi agent started"));
return;
}
if (type === "agent_end") {
console.log(pc.blue("Pi agent finished"));
return;
}
if (type === "turn_start") {
console.log(pc.blue("Turn started"));
return;
}
if (type === "turn_end") {
const message = asRecord(parsed.message);
if (message) {
const content = message.content as string | Array<{ type: string; text?: string }>;
const text = extractTextContent(content);
if (text) {
console.log(pc.green(`assistant: ${text}`));
}
}
return;
}
if (type === "message_update") {
const assistantEvent = asRecord(parsed.assistantMessageEvent);
if (assistantEvent) {
const msgType = asString(assistantEvent.type);
if (msgType === "text_delta") {
const delta = asString(assistantEvent.delta);
if (delta) {
console.log(pc.green(delta));
}
}
}
return;
}
if (type === "tool_execution_start") {
const toolName = asString(parsed.toolName);
const args = parsed.args;
console.log(pc.yellow(`tool_start: ${toolName}`));
if (args !== undefined) {
try {
console.log(pc.gray(JSON.stringify(args, null, 2)));
} catch {
console.log(pc.gray(String(args)));
}
}
return;
}
if (type === "tool_execution_end") {
const result = parsed.result;
const isError = parsed.isError === true;
const output = typeof result === "string" ? result : JSON.stringify(result);
if (output) {
console.log((isError ? pc.red : pc.gray)(output));
}
return;
}
console.log(line);
}

View file

@ -0,0 +1 @@
export { printPiStreamEvent } from "./format-event.js";

View file

@ -0,0 +1,40 @@
export const type = "pi_local";
export const label = "Pi (local)";
export const models: Array<{ id: string; label: string }> = [];
export const agentConfigurationDoc = `# pi_local agent configuration
Adapter: pi_local
Use when:
- You want Paperclip to run Pi (the AI coding agent) locally as the agent runtime
- You want provider/model routing in Pi format (--provider <name> --model <id>)
- You want Pi session resume across heartbeats via --session
- You need Pi's tool set (read, bash, edit, write, grep, find, ls)
Don't use when:
- You need webhook-style external invocation (use openclaw_gateway or http)
- You only need one-shot shell commands (use process)
- Pi CLI is not installed on the machine
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file appended to system prompt via --append-system-prompt
- promptTemplate (string, optional): user prompt template passed via -p flag
- model (string, required): Pi model id in provider/model format (for example xai/grok-4)
- thinking (string, optional): thinking level (off, minimal, low, medium, high, xhigh)
- command (string, optional): defaults to "pi"
- env (object, optional): KEY=VALUE environment variables
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Pi supports multiple providers and models. Use \`pi --list-models\` to list available options.
- Paperclip requires an explicit \`model\` value for \`pi_local\` agents.
- Sessions are stored in ~/.pi/paperclips/ and resumed with --session.
- All tools (read, bash, edit, write, grep, find, ls) are enabled by default.
- Agent instructions are appended to Pi's system prompt via --append-system-prompt, while the user task is sent via -p.
`;

View file

@ -0,0 +1,478 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
asString,
asNumber,
asStringArray,
parseObject,
buildPaperclipEnv,
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
import { ensurePiModelConfiguredAndAvailable } from "./models.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips");
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function parseModelProvider(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
if (!trimmed.includes("/")) return null;
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
}
function parseModelId(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
if (!trimmed.includes("/")) return trimmed || null;
return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null;
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills");
await fs.mkdir(piSkillsHome, { recursive: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const target = path.join(piSkillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
await onLog(
"stderr",
`[paperclip] Injected Pi skill "${entry.name}" into ${piSkillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject Pi skill "${entry.name}" into ${piSkillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
async function ensureSessionsDir(): Promise<string> {
await fs.mkdir(PAPERCLIP_SESSIONS_DIR, { recursive: true });
return PAPERCLIP_SESSIONS_DIR;
}
function buildSessionPath(agentId: string, timestamp: string): string {
const safeTimestamp = timestamp.replace(/[:.]/g, "-");
return path.join(PAPERCLIP_SESSIONS_DIR, `${safeTimestamp}-${agentId}.jsonl`);
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
const promptTemplate = asString(
config.promptTemplate,
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
);
const command = asString(config.command, "pi");
const model = asString(config.model, "").trim();
const thinking = asString(config.thinking, "").trim();
// Parse model into provider and model id
const provider = parseModelProvider(model);
const modelId = parseModelId(model);
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
// Ensure sessions directory exists
await ensureSessionsDir();
// Inject skills
await ensurePiSkillsInjected(onLog);
// Build environment
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
null;
const wakeReason =
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
? context.wakeReason.trim()
: null;
const wakeCommentId =
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
null;
const approvalId =
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
? context.approvalId.trim()
: null;
const approvalStatus =
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
? context.approvalStatus.trim()
: null;
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
await ensureCommandResolvable(command, cwd, runtimeEnv);
// Validate model is available before execution
await ensurePiModelConfiguredAndAvailable({
model,
command,
cwd,
env: runtimeEnv,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
// Handle session
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionPath = canResumeSession ? runtimeSessionId : buildSessionPath(agent.id, new Date().toISOString());
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
`[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
// Ensure session file exists (Pi requires this on first run)
if (!canResumeSession) {
try {
await fs.writeFile(sessionPath, "", { flag: "wx" });
} catch (err) {
// File may already exist, that's ok
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
throw err;
}
}
}
// Handle instructions file and build system prompt extension
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const resolvedInstructionsFilePath = instructionsFilePath
? path.resolve(cwd, instructionsFilePath)
: "";
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let systemPromptExtension = "";
let instructionsReadFailed = false;
if (resolvedInstructionsFilePath) {
try {
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
systemPromptExtension =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
);
} catch (err) {
instructionsReadFailed = true;
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
);
// Fall back to base prompt template
systemPromptExtension = promptTemplate;
}
} else {
systemPromptExtension = promptTemplate;
}
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
// User prompt is simple - just the rendered prompt template without instructions
const userPrompt = renderTemplate(promptTemplate, {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
});
const commandNotes = (() => {
if (!resolvedInstructionsFilePath) return [] as string[];
if (instructionsReadFailed) {
return [
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
];
}
return [
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
`Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`,
];
})();
const buildArgs = (sessionFile: string): string[] => {
const args: string[] = [];
// Use RPC mode for proper lifecycle management (waits for agent completion)
args.push("--mode", "rpc");
// Use --append-system-prompt to extend Pi's default system prompt
args.push("--append-system-prompt", renderedSystemPromptExtension);
if (provider) args.push("--provider", provider);
if (modelId) args.push("--model", modelId);
if (thinking) args.push("--thinking", thinking);
args.push("--tools", "read,bash,edit,write,grep,find,ls");
args.push("--session", sessionFile);
if (extraArgs.length > 0) args.push(...extraArgs);
return args;
};
const buildRpcStdin = (): string => {
// Send the prompt as an RPC command
const promptCommand = {
type: "prompt",
message: userPrompt,
};
return JSON.stringify(promptCommand) + "\n";
};
const runAttempt = async (sessionFile: string) => {
const args = buildArgs(sessionFile);
if (onMeta) {
await onMeta({
adapterType: "pi_local",
command,
cwd,
commandNotes,
commandArgs: args,
env: redactEnvForLogs(env),
prompt: userPrompt,
context,
});
}
// Buffer stdout by lines to handle partial JSON chunks
let stdoutBuffer = "";
const bufferedOnLog = async (stream: "stdout" | "stderr", chunk: string) => {
if (stream === "stderr") {
// Pass stderr through immediately (not JSONL)
await onLog(stream, chunk);
return;
}
// Buffer stdout and emit only complete lines
stdoutBuffer += chunk;
const lines = stdoutBuffer.split("\n");
// Keep the last (potentially incomplete) line in the buffer
stdoutBuffer = lines.pop() || "";
// Emit complete lines
for (const line of lines) {
if (line) {
await onLog(stream, line + "\n");
}
}
};
const proc = await runChildProcess(runId, command, args, {
cwd,
env: runtimeEnv,
timeoutSec,
graceSec,
onLog: bufferedOnLog,
stdin: buildRpcStdin(),
});
// Flush any remaining buffer content
if (stdoutBuffer) {
await onLog("stdout", stdoutBuffer);
}
return {
proc,
rawStderr: proc.stderr,
parsed: parsePiJsonl(proc.stdout),
};
};
const toResult = (
attempt: {
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
rawStderr: string;
parsed: ReturnType<typeof parsePiJsonl>;
},
clearSessionOnMissingSession = false,
): AdapterExecutionResult => {
if (attempt.proc.timedOut) {
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
clearSession: clearSessionOnMissingSession,
};
}
const resolvedSessionId = clearSessionOnMissingSession ? null : sessionPath;
const resolvedSessionParams = resolvedSessionId
? { sessionId: resolvedSessionId, cwd }
: null;
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const rawExitCode = attempt.proc.exitCode;
const fallbackErrorMessage = stderrLine || `Pi exited with code ${rawExitCode ?? -1}`;
return {
exitCode: rawExitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage: (rawExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
usage: {
inputTokens: attempt.parsed.usage.inputTokens,
outputTokens: attempt.parsed.usage.outputTokens,
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
},
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: provider,
model: model,
billingType: "unknown",
costUsd: attempt.parsed.usage.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.finalMessage ?? attempt.parsed.messages.join("\n\n").trim(),
clearSession: Boolean(clearSessionOnMissingSession),
};
};
const initial = await runAttempt(sessionPath);
const initialFailed =
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || initial.parsed.errors.length > 0);
if (
canResumeSession &&
initialFailed &&
isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) {
await onLog(
"stderr",
`[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`,
);
const newSessionPath = buildSessionPath(agent.id, new Date().toISOString());
try {
await fs.writeFile(newSessionPath, "", { flag: "wx" });
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
throw err;
}
}
const retry = await runAttempt(newSessionPath);
return toResult(retry, true);
}
return toResult(initial);
}

View file

@ -0,0 +1,60 @@
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId =
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id) ??
readNonEmptyString(record.session);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
return {
sessionId,
...(cwd ? { cwd } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.session);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
return {
sessionId,
...(cwd ? { cwd } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.session)
);
},
};
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export {
listPiModels,
discoverPiModels,
discoverPiModelsCached,
ensurePiModelConfiguredAndAvailable,
resetPiModelsCacheForTests,
} from "./models.js";
export { parsePiJsonl, isPiUnknownSessionError } from "./parse.js";

View file

@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import {
ensurePiModelConfiguredAndAvailable,
listPiModels,
resetPiModelsCacheForTests,
} from "./models.js";
describe("pi models", () => {
afterEach(() => {
delete process.env.PAPERCLIP_PI_COMMAND;
resetPiModelsCacheForTests();
});
it("returns an empty list when discovery command is unavailable", async () => {
process.env.PAPERCLIP_PI_COMMAND = "__paperclip_missing_pi_command__";
await expect(listPiModels()).resolves.toEqual([]);
});
it("rejects when model is missing", async () => {
await expect(
ensurePiModelConfiguredAndAvailable({ model: "" }),
).rejects.toThrow("Pi requires `adapterConfig.model`");
});
it("rejects when discovery cannot run for configured model", async () => {
process.env.PAPERCLIP_PI_COMMAND = "__paperclip_missing_pi_command__";
await expect(
ensurePiModelConfiguredAndAvailable({
model: "xai/grok-4",
}),
).rejects.toThrow();
});
});

View file

@ -0,0 +1,208 @@
import { createHash } from "node:crypto";
import type { AdapterModel } from "@paperclipai/adapter-utils";
import { asString, runChildProcess } from "@paperclipai/adapter-utils/server-utils";
const MODELS_CACHE_TTL_MS = 60_000;
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function parseModelsOutput(stdout: string): AdapterModel[] {
const parsed: AdapterModel[] = [];
const lines = stdout.split(/\r?\n/);
// Skip header line if present
let startIndex = 0;
if (lines.length > 0 && (lines[0].includes("provider") || lines[0].includes("model"))) {
startIndex = 1;
}
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Parse format: "provider model context max-out thinking images"
// Split by 2+ spaces to handle the columnar format
const parts = line.split(/\s{2,}/);
if (parts.length < 2) continue;
const provider = parts[0].trim();
const model = parts[1].trim();
if (!provider || !model) continue;
if (provider === "provider" && model === "model") continue; // Skip header
const id = `${provider}/${model}`;
parsed.push({ id, label: id });
}
return parsed;
}
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const deduped: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push({ id, label: model.label.trim() || id });
}
return deduped;
}
function sortModels(models: AdapterModel[]): AdapterModel[] {
return [...models].sort((a, b) =>
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
);
}
function resolvePiCommand(input: unknown): string {
const envOverride =
typeof process.env.PAPERCLIP_PI_COMMAND === "string" &&
process.env.PAPERCLIP_PI_COMMAND.trim().length > 0
? process.env.PAPERCLIP_PI_COMMAND.trim()
: "pi";
return asString(input, envOverride);
}
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]);
function isVolatileEnvKey(key: string): boolean {
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function hashValue(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
const envKey = Object.entries(env)
.filter(([key]) => !isVolatileEnvKey(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${hashValue(value)}`)
.join("\n");
return `${command}\n${cwd}\n${envKey}`;
}
function pruneExpiredDiscoveryCache(now: number) {
for (const [key, value] of discoveryCache.entries()) {
if (value.expiresAt <= now) discoveryCache.delete(key);
}
}
export async function discoverPiModels(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = resolvePiCommand(input.command);
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const runtimeEnv = normalizeEnv({ ...process.env, ...env });
const result = await runChildProcess(
`pi-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
["--list-models"],
{
cwd,
env: runtimeEnv,
timeoutSec: 20,
graceSec: 3,
onLog: async () => {},
},
);
if (result.timedOut) {
throw new Error("`pi --list-models` timed out.");
}
if ((result.exitCode ?? 1) !== 0) {
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
throw new Error(detail ? `\`pi --list-models\` failed: ${detail}` : "`pi --list-models` failed.");
}
return sortModels(dedupeModels(parseModelsOutput(result.stdout)));
}
function normalizeEnv(input: unknown): Record<string, string> {
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envInput)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
export async function discoverPiModelsCached(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = resolvePiCommand(input.command);
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const key = discoveryCacheKey(command, cwd, env);
const now = Date.now();
pruneExpiredDiscoveryCache(now);
const cached = discoveryCache.get(key);
if (cached && cached.expiresAt > now) return cached.models;
const models = await discoverPiModels({ command, cwd, env });
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
return models;
}
export async function ensurePiModelConfiguredAndAvailable(input: {
model?: unknown;
command?: unknown;
cwd?: unknown;
env?: unknown;
}): Promise<AdapterModel[]> {
const model = asString(input.model, "").trim();
if (!model) {
throw new Error("Pi requires `adapterConfig.model` in provider/model format.");
}
const models = await discoverPiModelsCached({
command: input.command,
cwd: input.cwd,
env: input.env,
});
if (models.length === 0) {
throw new Error("Pi returned no models. Run `pi --list-models` and verify provider auth.");
}
if (!models.some((entry) => entry.id === model)) {
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
throw new Error(
`Configured Pi model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
);
}
return models;
}
export async function listPiModels(): Promise<AdapterModel[]> {
try {
return await discoverPiModelsCached();
} catch {
return [];
}
}
export function resetPiModelsCacheForTests() {
discoveryCache.clear();
}

View file

@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import { parsePiJsonl, isPiUnknownSessionError } from "./parse.js";
describe("parsePiJsonl", () => {
it("parses agent lifecycle and messages", () => {
const stdout = [
JSON.stringify({ type: "agent_start" }),
JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: [{ type: "text", text: "Hello from Pi" }],
},
}),
JSON.stringify({ type: "agent_end", messages: [] }),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.messages).toContain("Hello from Pi");
expect(parsed.finalMessage).toBe("Hello from Pi");
});
it("parses streaming text deltas", () => {
const stdout = [
JSON.stringify({
type: "message_update",
assistantMessageEvent: { type: "text_delta", delta: "Hello " },
}),
JSON.stringify({
type: "message_update",
assistantMessageEvent: { type: "text_delta", delta: "World" },
}),
JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: "Hello World",
},
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.messages).toContain("Hello World");
});
it("parses tool execution", () => {
const stdout = [
JSON.stringify({
type: "tool_execution_start",
toolCallId: "tool_1",
toolName: "read",
args: { path: "/tmp/test.txt" },
}),
JSON.stringify({
type: "tool_execution_end",
toolCallId: "tool_1",
toolName: "read",
result: "file contents",
isError: false,
}),
JSON.stringify({
type: "turn_end",
message: { role: "assistant", content: "Done" },
toolResults: [
{
toolCallId: "tool_1",
content: "file contents",
isError: false,
},
],
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.toolCalls).toHaveLength(1);
expect(parsed.toolCalls[0].toolName).toBe("read");
expect(parsed.toolCalls[0].result).toBe("file contents");
expect(parsed.toolCalls[0].isError).toBe(false);
});
it("handles errors in tool execution", () => {
const stdout = [
JSON.stringify({
type: "tool_execution_start",
toolCallId: "tool_1",
toolName: "read",
args: { path: "/missing.txt" },
}),
JSON.stringify({
type: "tool_execution_end",
toolCallId: "tool_1",
toolName: "read",
result: "File not found",
isError: true,
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.toolCalls).toHaveLength(1);
expect(parsed.toolCalls[0].isError).toBe(true);
expect(parsed.toolCalls[0].result).toBe("File not found");
});
it("extracts usage and cost from turn_end events", () => {
const stdout = [
JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: "Response with usage",
usage: {
input: 100,
output: 50,
cacheRead: 20,
totalTokens: 170,
cost: {
input: 0.001,
output: 0.0015,
cacheRead: 0.0001,
cacheWrite: 0,
total: 0.0026,
},
},
},
toolResults: [],
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.usage.inputTokens).toBe(100);
expect(parsed.usage.outputTokens).toBe(50);
expect(parsed.usage.cachedInputTokens).toBe(20);
expect(parsed.usage.costUsd).toBeCloseTo(0.0026, 4);
});
it("accumulates usage from multiple turns", () => {
const stdout = [
JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: "First response",
usage: {
input: 50,
output: 25,
cacheRead: 0,
cost: { total: 0.001 },
},
},
}),
JSON.stringify({
type: "turn_end",
message: {
role: "assistant",
content: "Second response",
usage: {
input: 30,
output: 20,
cacheRead: 10,
cost: { total: 0.0015 },
},
},
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.usage.inputTokens).toBe(80);
expect(parsed.usage.outputTokens).toBe(45);
expect(parsed.usage.cachedInputTokens).toBe(10);
expect(parsed.usage.costUsd).toBeCloseTo(0.0025, 4);
});
it("handles standalone usage events with Pi format", () => {
const stdout = [
JSON.stringify({
type: "usage",
usage: {
input: 200,
output: 100,
cacheRead: 50,
cost: { total: 0.005 },
},
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.usage.inputTokens).toBe(200);
expect(parsed.usage.outputTokens).toBe(100);
expect(parsed.usage.cachedInputTokens).toBe(50);
expect(parsed.usage.costUsd).toBe(0.005);
});
it("handles standalone usage events with generic format", () => {
const stdout = [
JSON.stringify({
type: "usage",
usage: {
inputTokens: 150,
outputTokens: 75,
cachedInputTokens: 25,
costUsd: 0.003,
},
}),
].join("\n");
const parsed = parsePiJsonl(stdout);
expect(parsed.usage.inputTokens).toBe(150);
expect(parsed.usage.outputTokens).toBe(75);
expect(parsed.usage.cachedInputTokens).toBe(25);
expect(parsed.usage.costUsd).toBe(0.003);
});
});
describe("isPiUnknownSessionError", () => {
it("detects unknown session errors", () => {
expect(isPiUnknownSessionError("session not found: s_123", "")).toBe(true);
expect(isPiUnknownSessionError("", "unknown session id")).toBe(true);
expect(isPiUnknownSessionError("", "no session available")).toBe(true);
expect(isPiUnknownSessionError("all good", "")).toBe(false);
expect(isPiUnknownSessionError("working fine", "no errors")).toBe(false);
});
});

View file

@ -0,0 +1,211 @@
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
interface ParsedPiOutput {
sessionId: string | null;
messages: string[];
errors: string[];
usage: {
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
costUsd: number;
};
finalMessage: string | null;
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; result: string | null; isError: boolean }>;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!)
.join("");
}
export function parsePiJsonl(stdout: string): ParsedPiOutput {
const result: ParsedPiOutput = {
sessionId: null,
messages: [],
errors: [],
usage: {
inputTokens: 0,
outputTokens: 0,
cachedInputTokens: 0,
costUsd: 0,
},
finalMessage: null,
toolCalls: [],
};
let currentToolCall: { toolCallId: string; toolName: string; args: unknown } | null = null;
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const event = parseJson(line);
if (!event) continue;
const eventType = asString(event.type, "");
// RPC protocol messages - skip these (internal implementation detail)
if (eventType === "response" || eventType === "extension_ui_request" || eventType === "extension_ui_response" || eventType === "extension_error") {
continue;
}
// Agent lifecycle
if (eventType === "agent_start") {
continue;
}
if (eventType === "agent_end") {
const messages = event.messages as Array<Record<string, unknown>> | undefined;
if (messages && messages.length > 0) {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === "assistant") {
const content = lastMessage.content as string | Array<{ type: string; text?: string }>;
result.finalMessage = extractTextContent(content);
}
}
continue;
}
// Turn lifecycle
if (eventType === "turn_start") {
continue;
}
if (eventType === "turn_end") {
const message = asRecord(event.message);
if (message) {
const content = message.content as string | Array<{ type: string; text?: string }>;
const text = extractTextContent(content);
if (text) {
result.finalMessage = text;
result.messages.push(text);
}
// Extract usage and cost from assistant message
const usage = asRecord(message.usage);
if (usage) {
result.usage.inputTokens += asNumber(usage.input, 0);
result.usage.outputTokens += asNumber(usage.output, 0);
result.usage.cachedInputTokens += asNumber(usage.cacheRead, 0);
// Pi stores cost in usage.cost.total (and broken down in usage.cost.input, etc.)
const cost = asRecord(usage.cost);
if (cost) {
result.usage.costUsd += asNumber(cost.total, 0);
}
}
}
// Tool results are in toolResults array
const toolResults = event.toolResults as Array<Record<string, unknown>> | undefined;
if (toolResults) {
for (const tr of toolResults) {
const toolCallId = asString(tr.toolCallId, "");
const content = tr.content;
const isError = tr.isError === true;
// Find matching tool call by toolCallId
const existingCall = result.toolCalls.find((tc) => tc.toolCallId === toolCallId);
if (existingCall) {
existingCall.result = typeof content === "string" ? content : JSON.stringify(content);
existingCall.isError = isError;
}
}
}
continue;
}
// Message updates (streaming)
if (eventType === "message_update") {
const assistantEvent = asRecord(event.assistantMessageEvent);
if (assistantEvent) {
const msgType = asString(assistantEvent.type, "");
if (msgType === "text_delta") {
const delta = asString(assistantEvent.delta, "");
if (delta) {
// Append to last message or create new
if (result.messages.length === 0) {
result.messages.push(delta);
} else {
result.messages[result.messages.length - 1] += delta;
}
}
}
}
continue;
}
// Tool execution
if (eventType === "tool_execution_start") {
const toolCallId = asString(event.toolCallId, "");
const toolName = asString(event.toolName, "");
const args = event.args;
currentToolCall = { toolCallId, toolName, args };
result.toolCalls.push({
toolCallId,
toolName,
args,
result: null,
isError: false,
});
continue;
}
if (eventType === "tool_execution_end") {
const toolCallId = asString(event.toolCallId, "");
const toolName = asString(event.toolName, "");
const toolResult = event.result;
const isError = event.isError === true;
// Find the tool call by toolCallId (not toolName, to handle multiple calls to same tool)
const existingCall = result.toolCalls.find((tc) => tc.toolCallId === toolCallId);
if (existingCall) {
existingCall.result = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult);
existingCall.isError = isError;
}
currentToolCall = null;
continue;
}
// Usage tracking if available in the event (fallback for standalone usage events)
if (eventType === "usage" || event.usage) {
const usage = asRecord(event.usage);
if (usage) {
// Support both Pi format (input/output/cacheRead) and generic format (inputTokens/outputTokens/cachedInputTokens)
result.usage.inputTokens += asNumber(usage.inputTokens ?? usage.input, 0);
result.usage.outputTokens += asNumber(usage.outputTokens ?? usage.output, 0);
result.usage.cachedInputTokens += asNumber(usage.cachedInputTokens ?? usage.cacheRead, 0);
// Cost may be in usage.costUsd (direct) or usage.cost.total (Pi format)
const cost = asRecord(usage.cost);
if (cost) {
result.usage.costUsd += asNumber(cost.total ?? usage.costUsd, 0);
} else {
result.usage.costUsd += asNumber(usage.costUsd, 0);
}
}
}
}
return result;
}
export function isPiUnknownSessionError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /unknown\s+session|session\s+not\s+found|session\s+.*\s+not\s+found|no\s+session/i.test(haystack);
}

View file

@ -0,0 +1,276 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asString,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
asStringArray,
} from "@paperclipai/adapter-utils/server-utils";
import { discoverPiModelsCached } from "./models.js";
import { parsePiJsonl } from "./parse.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean;
}
function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
const PI_AUTH_REQUIRED_RE =
/(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|free\s+usage\s+exceeded)/i;
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "pi");
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
checks.push({
code: "pi_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "pi_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
const envConfig = parseObject(config.env);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
const cwdInvalid = checks.some((check) => check.code === "pi_cwd_invalid");
if (cwdInvalid) {
checks.push({
code: "pi_command_skipped",
level: "warn",
message: "Skipped command check because working directory validation failed.",
detail: command,
});
} else {
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "pi_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "pi_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
}
const canRunProbe =
checks.every((check) => check.code !== "pi_cwd_invalid" && check.code !== "pi_command_unresolvable");
if (canRunProbe) {
try {
const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
checks.push({
code: "pi_models_discovered",
level: "info",
message: `Discovered ${discovered.length} model(s) from Pi.`,
});
} else {
checks.push({
code: "pi_models_empty",
level: "warn",
message: "Pi returned no models.",
hint: "Run `pi --list-models` and verify provider authentication.",
});
}
} catch (err) {
checks.push({
code: "pi_models_discovery_failed",
level: "warn",
message: err instanceof Error ? err.message : "Pi model discovery failed.",
hint: "Run `pi --list-models` manually to verify provider auth and config.",
});
}
}
const configuredModel = asString(config.model, "").trim();
if (!configuredModel) {
checks.push({
code: "pi_model_required",
level: "error",
message: "Pi requires a configured model in provider/model format.",
hint: "Set adapterConfig.model using an ID from `pi --list-models`.",
});
} else if (canRunProbe) {
// Verify model is in the list
try {
const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv });
const modelExists = discovered.some((m: { id: string }) => m.id === configuredModel);
if (modelExists) {
checks.push({
code: "pi_model_configured",
level: "info",
message: `Configured model: ${configuredModel}`,
});
} else {
checks.push({
code: "pi_model_not_found",
level: "warn",
message: `Configured model "${configuredModel}" not found in available models.`,
hint: "Run `pi --list-models` and choose a currently available provider/model ID.",
});
}
} catch {
// If we can't verify, just note it
checks.push({
code: "pi_model_configured",
level: "info",
message: `Configured model: ${configuredModel}`,
});
}
}
if (canRunProbe && configuredModel) {
// Parse model for probe
const provider = configuredModel.includes("/")
? configuredModel.slice(0, configuredModel.indexOf("/"))
: "";
const modelId = configuredModel.includes("/")
? configuredModel.slice(configuredModel.indexOf("/") + 1)
: configuredModel;
const thinking = asString(config.thinking, "").trim();
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const args = ["-p", "Respond with hello.", "--mode", "json"];
if (provider) args.push("--provider", provider);
if (modelId) args.push("--model", modelId);
if (thinking) args.push("--thinking", thinking);
args.push("--tools", "read");
if (extraArgs.length > 0) args.push(...extraArgs);
try {
const probe = await runChildProcess(
`pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{
cwd,
env: runtimeEnv,
timeoutSec: 60,
graceSec: 5,
onLog: async () => {},
},
);
const parsed = parsePiJsonl(probe.stdout);
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errors[0] ?? null);
const authEvidence = `${parsed.errors.join("\n")}\n${probe.stdout}\n${probe.stderr}`.trim();
if (probe.timedOut) {
checks.push({
code: "pi_hello_probe_timed_out",
level: "warn",
message: "Pi hello probe timed out.",
hint: "Retry the probe. If this persists, run Pi manually in this working directory.",
});
} else if ((probe.exitCode ?? 1) === 0 && parsed.errors.length === 0) {
const summary = (parsed.finalMessage || parsed.messages.join(" ")).trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
code: hasHello ? "pi_hello_probe_passed" : "pi_hello_probe_unexpected_output",
level: hasHello ? "info" : "warn",
message: hasHello
? "Pi hello probe succeeded."
: "Pi probe ran but did not return `hello` as expected.",
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
...(hasHello
? {}
: {
hint: "Run `pi --mode json` manually and prompt `Respond with hello` to inspect output.",
}),
});
} else if (PI_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "pi_hello_probe_auth_required",
level: "warn",
message: "Pi is installed, but provider authentication is not ready.",
...(detail ? { detail } : {}),
hint: "Set provider API key environment variable (e.g., ANTHROPIC_API_KEY, XAI_API_KEY) and retry.",
});
} else {
checks.push({
code: "pi_hello_probe_failed",
level: "error",
message: "Pi hello probe failed.",
...(detail ? { detail } : {}),
hint: "Run `pi --mode json` manually in this working directory to debug.",
});
}
} catch (err) {
checks.push({
code: "pi_hello_probe_failed",
level: "error",
message: "Pi hello probe failed.",
detail: err instanceof Error ? err.message : String(err),
hint: "Run `pi --mode json` manually in this working directory to debug.",
});
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View file

@ -0,0 +1,71 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;
// Pi sessions can run until the CLI exits naturally; keep timeout disabled (0)
ac.timeoutSec = 0;
ac.graceSec = 20;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (Object.keys(env).length > 0) ac.env = env;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = v.extraArgs;
if (v.args) ac.args = v.args;
return ac;
}

View file

@ -0,0 +1,2 @@
export { parsePiStdoutLine } from "./parse-stdout.js";
export { buildPiLocalConfig } from "./build-config.js";

View file

@ -0,0 +1,147 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text!)
.join("");
}
export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
}
const type = asString(parsed.type);
// RPC protocol messages - filter these out (internal implementation detail)
if (type === "response" || type === "extension_ui_request" || type === "extension_ui_response" || type === "extension_error") {
return [];
}
// Agent lifecycle
if (type === "agent_start") {
return [{ kind: "system", ts, text: "Pi agent started" }];
}
if (type === "agent_end") {
return [{ kind: "system", ts, text: "Pi agent finished" }];
}
// Turn lifecycle
if (type === "turn_start") {
return [{ kind: "system", ts, text: "Turn started" }];
}
if (type === "turn_end") {
const message = asRecord(parsed.message);
const toolResults = parsed.toolResults as Array<Record<string, unknown>> | undefined;
const entries: TranscriptEntry[] = [];
if (message) {
const content = message.content as string | Array<{ type: string; text?: string }>;
const text = extractTextContent(content);
if (text) {
entries.push({ kind: "assistant", ts, text });
}
}
// Process tool results
if (toolResults) {
for (const tr of toolResults) {
const content = tr.content;
const isError = tr.isError === true;
const contentStr = typeof content === "string" ? content : JSON.stringify(content);
entries.push({
kind: "tool_result",
ts,
toolUseId: asString(tr.toolCallId, "unknown"),
content: contentStr,
isError,
});
}
}
return entries.length > 0 ? entries : [{ kind: "system", ts, text: "Turn ended" }];
}
// Message streaming
if (type === "message_start") {
return [];
}
if (type === "message_update") {
const assistantEvent = asRecord(parsed.assistantMessageEvent);
if (assistantEvent) {
const msgType = asString(assistantEvent.type);
if (msgType === "text_delta") {
const delta = asString(assistantEvent.delta);
if (delta) {
return [{ kind: "assistant", ts, text: delta, delta: true }];
}
}
}
return [];
}
if (type === "message_end") {
return [];
}
// Tool execution
if (type === "tool_execution_start") {
const toolName = asString(parsed.toolName);
const args = parsed.args;
if (toolName) {
return [{
kind: "tool_call",
ts,
name: toolName,
input: args,
}];
}
return [{ kind: "system", ts, text: `Tool started` }];
}
if (type === "tool_execution_update") {
return [];
}
if (type === "tool_execution_end") {
const toolCallId = asString(parsed.toolCallId);
const result = parsed.result;
const isError = parsed.isError === true;
const contentStr = typeof result === "string" ? result : JSON.stringify(result);
return [{
kind: "tool_result",
ts,
toolUseId: toolCallId || "unknown",
content: contentStr,
isError,
}];
}
return [{ kind: "stdout", ts, text: line }];
}

View file

@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});

View file

@ -38,6 +38,7 @@
"postgres": "^3.4.5"
},
"devDependencies": {
"@types/node": "^24.6.0",
"drizzle-kit": "^0.31.9",
"tsx": "^4.19.2",
"typescript": "^5.7.3",

View file

@ -2,12 +2,13 @@ import { createHash } from "node:crypto";
import { drizzle as drizzlePg } from "drizzle-orm/postgres-js";
import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator";
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import postgres from "postgres";
import * as schema from "./schema/index.js";
const MIGRATIONS_FOLDER = new URL("./migrations", import.meta.url).pathname;
const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url));
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const MIGRATIONS_JOURNAL_JSON = new URL("./migrations/meta/_journal.json", import.meta.url).pathname;
const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url));
function isSafeIdentifier(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
@ -702,8 +703,7 @@ export async function migratePostgresIfEmpty(url: string): Promise<MigrationBoot
}
const db = drizzlePg(sql);
const migrationsFolder = new URL("./migrations", import.meta.url).pathname;
await migratePg(db, { migrationsFolder });
await migratePg(db, { migrationsFolder: MIGRATIONS_FOLDER });
return { migrated: true, reason: "migrated-empty-db", tableCount: 0 };
} finally {

View file

@ -0,0 +1,2 @@
CREATE INDEX "issue_comments_company_issue_created_at_idx" ON "issue_comments" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint
CREATE INDEX "issue_comments_company_author_issue_created_at_idx" ON "issue_comments" USING btree ("company_id","author_user_id","issue_id","created_at");

View file

@ -0,0 +1,15 @@
CREATE TABLE "issue_read_states" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"user_id" text NOT NULL,
"last_read_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "issue_read_states" ADD CONSTRAINT "issue_read_states_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_read_states" ADD CONSTRAINT "issue_read_states_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issue_read_states_company_issue_idx" ON "issue_read_states" USING btree ("company_id","issue_id");--> statement-breakpoint
CREATE INDEX "issue_read_states_company_user_idx" ON "issue_read_states" USING btree ("company_id","user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "issue_read_states_company_issue_user_idx" ON "issue_read_states" USING btree ("company_id","issue_id","user_id");

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -169,6 +169,20 @@
"when": 1772139727599,
"tag": "0023_fair_lethal_legion",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1772806603601,
"tag": "0024_far_beast",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1772807461603,
"tag": "0025_nasty_salo",
"breakpoints": true
}
]
}

View file

@ -20,6 +20,7 @@ export { labels } from "./labels.js";
export { issueLabels } from "./issue_labels.js";
export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js";
export { issueReadStates } from "./issue_read_states.js";
export { assets } from "./assets.js";
export { issueAttachments } from "./issue_attachments.js";
export { heartbeatRuns } from "./heartbeat_runs.js";

View file

@ -18,5 +18,16 @@ export const issueComments = pgTable(
(table) => ({
issueIdx: index("issue_comments_issue_idx").on(table.issueId),
companyIdx: index("issue_comments_company_idx").on(table.companyId),
companyIssueCreatedAtIdx: index("issue_comments_company_issue_created_at_idx").on(
table.companyId,
table.issueId,
table.createdAt,
),
companyAuthorIssueCreatedAtIdx: index("issue_comments_company_author_issue_created_at_idx").on(
table.companyId,
table.authorUserId,
table.issueId,
table.createdAt,
),
}),
);

View file

@ -0,0 +1,25 @@
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
export const issueReadStates = pgTable(
"issue_read_states",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id),
userId: text("user_id").notNull(),
lastReadAt: timestamp("last_read_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIssueIdx: index("issue_read_states_company_issue_idx").on(table.companyId, table.issueId),
companyUserIdx: index("issue_read_states_company_user_idx").on(table.companyId, table.userId),
companyIssueUserUnique: uniqueIndex("issue_read_states_company_issue_user_idx").on(
table.companyId,
table.issueId,
table.userId,
),
}),
);

View file

@ -55,6 +55,7 @@ export const serverConfigSchema = z.object({
export const authConfigSchema = z.object({
baseUrlMode: z.enum(AUTH_BASE_URL_MODES).default("auto"),
publicBaseUrl: z.string().url().optional(),
disableSignUp: z.boolean().default(false),
});
export const storageLocalDiskConfigSchema = z.object({
@ -103,6 +104,7 @@ export const paperclipConfigSchema = z
server: serverConfigSchema,
auth: authConfigSchema.default({
baseUrlMode: "auto",
disableSignUp: false,
}),
storage: storageConfigSchema.default({
provider: "local_disk",

View file

@ -21,7 +21,16 @@ export const AGENT_STATUSES = [
] as const;
export type AgentStatus = (typeof AGENT_STATUSES)[number];
export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "opencode_local", "cursor", "openclaw"] as const;
export const AGENT_ADAPTER_TYPES = [
"process",
"http",
"claude_local",
"codex_local",
"opencode_local",
"pi_local",
"cursor",
"openclaw_gateway",
] as const;
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
export const AGENT_ROLES = [
@ -39,6 +48,20 @@ export const AGENT_ROLES = [
] as const;
export type AgentRole = (typeof AGENT_ROLES)[number];
export const AGENT_ROLE_LABELS: Record<AgentRole, string> = {
ceo: "CEO",
cto: "CTO",
cmo: "CMO",
cfo: "CFO",
engineer: "Engineer",
designer: "Designer",
pm: "PM",
qa: "QA",
devops: "DevOps",
researcher: "Researcher",
general: "General",
};
export const AGENT_ICON_NAMES = [
"bot",
"cpu",

View file

@ -6,6 +6,7 @@ export {
AGENT_STATUSES,
AGENT_ADAPTER_TYPES,
AGENT_ROLES,
AGENT_ROLE_LABELS,
AGENT_ICON_NAMES,
ISSUE_STATUSES,
ISSUE_PRIORITIES,
@ -197,6 +198,7 @@ export {
updateBudgetSchema,
createAssetImageMetadataSchema,
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
acceptInviteSchema,
listJoinRequestsQuerySchema,
claimJoinRequestApiKeySchema,
@ -206,6 +208,7 @@ export {
type UpdateBudget,
type CreateAssetImageMetadata,
type CreateCompanyInvite,
type CreateOpenClawInvitePrompt,
type AcceptInvite,
type ListJoinRequestsQuery,
type ClaimJoinRequestApiKey,

View file

@ -82,6 +82,9 @@ export interface Issue {
project?: Project | null;
goal?: Goal | null;
mentionedProjects?: Project[];
myLastTouchAt?: Date | null;
lastExternalCommentAt?: Date | null;
isUnreadForMe?: boolean;
createdAt: Date;
updatedAt: Date;
}

View file

@ -9,18 +9,32 @@ import {
export const createCompanyInviteSchema = z.object({
allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"),
expiresInHours: z.number().int().min(1).max(24 * 30).optional().default(72),
defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(),
agentMessage: z.string().max(4000).optional().nullable(),
});
export type CreateCompanyInvite = z.infer<typeof createCompanyInviteSchema>;
export const createOpenClawInvitePromptSchema = z.object({
agentMessage: z.string().max(4000).optional().nullable(),
});
export type CreateOpenClawInvitePrompt = z.infer<
typeof createOpenClawInvitePromptSchema
>;
export const acceptInviteSchema = z.object({
requestType: z.enum(JOIN_REQUEST_TYPES),
agentName: z.string().min(1).max(120).optional(),
adapterType: z.enum(AGENT_ADAPTER_TYPES).optional(),
capabilities: z.string().max(4000).optional().nullable(),
agentDefaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(),
// OpenClaw join compatibility fields accepted at top level.
responsesWebhookUrl: z.string().max(4000).optional().nullable(),
responsesWebhookMethod: z.string().max(32).optional().nullable(),
responsesWebhookHeaders: z.record(z.string(), z.unknown()).optional().nullable(),
paperclipApiUrl: z.string().max(4000).optional().nullable(),
webhookAuthHeader: z.string().max(4000).optional().nullable(),
});
export type AcceptInvite = z.infer<typeof acceptInviteSchema>;

View file

@ -119,12 +119,14 @@ export {
export {
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
acceptInviteSchema,
listJoinRequestsQuerySchema,
claimJoinRequestApiKeySchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
type CreateCompanyInvite,
type CreateOpenClawInvitePrompt,
type AcceptInvite,
type ListJoinRequestsQuery,
type ClaimJoinRequestApiKey,