fix(plugins): re-export probe symbols + declare plugin deps in dashboard

- Hermes / OpenClaw plugin index.ts now re-export `probeHermesBinary` /
  `probeOpenClawBinary` and their status types so the dashboard's
  `runtime-provider-probes.ts` façade can import them via the public
  package entry instead of deep paths.
- Dashboard `package.json` adds `@fusion-plugin-examples/hermes-runtime`,
  `…/openclaw-runtime`, `…/paperclip-runtime` as workspace deps so
  pnpm symlinks them into `packages/dashboard/node_modules/`. Without
  these, the new probe imports failed with "Cannot find module" during
  `pnpm typecheck`.

This clears 6 of the 9 outstanding typecheck errors. The remaining 3 are
in the in-flight Hermes plugin rewrite (runtime-adapter still imports
from a deleted `./pi-module.js`; the new `index.ts` calls a factory
with the wrong arg type) and should be resolved by the same change set
that landed the rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-27 20:22:18 -07:00
committed by gsxdsm
parent 5159cc2a68
commit 6e65786bc5
82 changed files with 8481 additions and 2833 deletions

View File

@@ -1,26 +1,38 @@
import { randomUUID } from "node:crypto";
import {
ConflictError,
agentsMe,
createIssue,
checkoutIssue,
discoverPaperclipCliConfig,
getIssue,
getIssueComments,
invokeHeartbeat,
getRunEvents,
resolvePaperclipConfig,
} from "./pi-module.js";
wakeAgent,
type RunEvent,
} from "./paperclip-client.js";
import type {
AgentRuntime,
AgentRuntimeOptions,
AgentSessionResult,
PaperclipMode,
PaperclipRuntimeConfig,
PaperclipSession,
RuntimeLogger,
} from "./types.js";
const POLL_INITIAL_INTERVAL_MS = 2_000;
const POLL_MAX_INTERVAL_MS = 10_000;
const POLL_TIMEOUT_MS = 120_000;
const TERMINAL_STATUSES = new Set(["done", "cancelled", "in_review"]);
/** Run-level statuses that signal we should stop polling events. */
const TERMINAL_RUN_STATUSES = new Set<string>([
"succeeded",
"failed",
"cancelled",
"timed_out",
]);
const VALID_MODES: ReadonlySet<PaperclipMode> = new Set([
"issue-per-prompt",
"rolling-issue",
"wakeup-only",
]);
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -31,7 +43,8 @@ function asString(value: unknown): string | undefined {
}
function deriveIssueTitle(prompt: string): string {
const firstLine = prompt.split("\n").find((line) => line.trim() !== "") ?? "Fusion runtime prompt";
const firstLine =
prompt.split("\n").find((line) => line.trim() !== "") ?? "Fusion runtime prompt";
return firstLine.slice(0, 200);
}
@@ -43,35 +56,6 @@ function buildIssueDescription(session: PaperclipSession, prompt: string): strin
].join("\n\n");
}
function collectCommentText(comments: Array<Record<string, unknown>>): { text: string; thinking: string } {
const textParts: string[] = [];
const thinkingParts: string[] = [];
for (const comment of comments) {
const body = asString(comment.body)?.trim();
if (!body) {
continue;
}
textParts.push(body);
const kind = asString(comment.kind) ?? asString(comment.type);
if (kind === "thinking" || kind === "reasoning") {
thinkingParts.push(body);
continue;
}
if (body.toLowerCase().startsWith("thinking:")) {
thinkingParts.push(body.replace(/^thinking:\s*/i, ""));
}
}
return {
text: textParts.join("\n\n"),
thinking: thinkingParts.join("\n\n"),
};
}
function pickIssueId(issue: Record<string, unknown>): string {
const issueId = asString(issue.id);
if (!issueId) {
@@ -80,10 +64,20 @@ function pickIssueId(issue: Record<string, unknown>): string {
return issueId;
}
function pickIssueStatus(issue: Record<string, unknown>): string {
return asString(issue.status) ?? "unknown";
function normalizeMode(mode: string | undefined): PaperclipMode {
if (mode && VALID_MODES.has(mode as PaperclipMode)) return mode as PaperclipMode;
return "rolling-issue";
}
/**
* Adapter that drives Paperclip via its modern wakeup + heartbeat-run streaming API.
*
* Flow per prompt:
* 1. Optional issue creation/reuse (depending on `mode`).
* 2. POST /api/agents/{agentId}/wakeup with idempotencyKey + payload.
* 3. Stream GET /api/heartbeat-runs/{runId}/events; forward log chunks.
* 4. Once terminal, fetch the issue + comments for a final answer.
*/
export class PaperclipRuntimeAdapter implements AgentRuntime {
readonly id = "paperclip";
readonly name = "Paperclip Runtime";
@@ -92,31 +86,82 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
private readonly logger: RuntimeLogger;
constructor(config?: Partial<PaperclipRuntimeConfig>, logger?: RuntimeLogger) {
const resolved = resolvePaperclipConfig(
config as Record<string, unknown> | undefined,
);
// resolvePaperclipConfig returns mode as string; narrow at the boundary.
this.config = {
...resolvePaperclipConfig(config as Record<string, unknown> | undefined),
...resolved,
mode: normalizeMode(resolved.mode),
...config,
};
this.logger = logger ?? console;
}
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
if (!this.config.agentId || !this.config.companyId) {
const missing = [!this.config.agentId ? "agentId" : null, !this.config.companyId ? "companyId" : null]
.filter(Boolean)
.join(", ");
let effectiveApiUrl = this.config.apiUrl;
let effectiveApiKey = this.config.apiKey;
// CLI transport: read apiUrl (and possibly apiKey) from local paperclipai config.
if (this.config.transport === "cli") {
const discovery = await discoverPaperclipCliConfig({
configPath: this.config.cliConfigPath,
});
if (!discovery.ok) {
throw new Error(
`Paperclip CLI mode failed: ${discovery.reason} (Switch to API mode in settings if paperclipai isn't installed.)`,
);
}
effectiveApiUrl = discovery.apiUrl;
// Only override apiKey if the user didn't explicitly set one.
if (!effectiveApiKey) {
effectiveApiKey = discovery.apiKey;
}
this.logger.info(
`Paperclip CLI mode resolved apiUrl=${effectiveApiUrl} (deploymentMode=${discovery.deploymentMode ?? "unknown"})`,
);
}
let agentId = this.config.agentId;
let companyId = this.config.companyId;
// Auto-derive agentId/companyId from /agents/me when missing.
if (!agentId || !companyId) {
try {
const me = await agentsMe(effectiveApiUrl, effectiveApiKey);
agentId = agentId ?? me.agentId;
companyId = companyId ?? me.companyId;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Paperclip runtime could not derive agentId/companyId from /agents/me. Configure them explicitly or check the API key. Underlying error: ${reason}`,
);
}
}
if (!agentId || !companyId) {
throw new Error(
`Paperclip runtime is missing required config: ${missing}. Configure plugin settings (apiUrl, apiKey, agentId, companyId) or PAPERCLIP_* environment variables.`,
"Paperclip runtime is missing required config: agentId or companyId. Configure plugin settings (apiUrl, apiKey, agentId, companyId) or PAPERCLIP_* env vars.",
);
}
const session: PaperclipSession = {
apiUrl: this.config.apiUrl,
apiKey: this.config.apiKey,
agentId: this.config.agentId,
companyId: this.config.companyId,
apiUrl: effectiveApiUrl,
apiKey: effectiveApiKey,
agentId,
companyId,
sessionId: randomUUID(),
systemPrompt: options.systemPrompt,
cwd: options.cwd,
mode: normalizeMode(this.config.mode),
parentIssueId: this.config.parentIssueId,
projectId: this.config.projectId,
goalId: this.config.goalId,
issueId: undefined,
turnIndex: 0,
runTimeoutMs: this.config.runTimeoutMs ?? 600_000,
pollIntervalMs: this.config.pollIntervalMs ?? 500,
pollIntervalMaxMs: this.config.pollIntervalMaxMs ?? 2_000,
onText: options.onText,
onThinking: options.onThinking,
onToolStart: options.onToolStart,
@@ -124,10 +169,7 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
dispose: () => undefined,
};
return {
session,
sessionFile: undefined,
};
return { session, sessionFile: undefined };
}
async promptWithFallback(
@@ -135,53 +177,104 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
prompt: string,
_options?: unknown,
): Promise<void> {
session.onToolStart?.("paperclip.issue", { sessionId: session.sessionId });
const createdIssue = await createIssue(session.apiUrl, session.apiKey, session.companyId, {
title: deriveIssueTitle(prompt),
description: buildIssueDescription(session, prompt),
status: "backlog",
assigneeAgentId: session.agentId,
session.turnIndex += 1;
const turn = session.turnIndex;
session.onToolStart?.("paperclip.run", {
sessionId: session.sessionId,
mode: session.mode,
turn,
});
const issueId = pickIssueId(createdIssue);
// ---- Stage 1: issue create/reuse ------------------------------------
let issueId: string | undefined;
if (session.mode === "issue-per-prompt") {
issueId = await this.createIssueForPrompt(session, prompt);
} else if (session.mode === "rolling-issue") {
if (!session.issueId) {
session.issueId = await this.createIssueForPrompt(session, prompt);
}
issueId = session.issueId;
}
// wakeup-only: no issue side-effect.
// ---- Stage 2: wakeup -------------------------------------------------
const idempotencyKey = `${session.sessionId}:${turn}`;
let runId: string;
try {
await checkoutIssue(session.apiUrl, session.apiKey, issueId, session.agentId, session.sessionId);
const wakeResponse = await wakeAgent(session.apiUrl, session.apiKey, session.agentId, {
source: "on_demand",
triggerDetail: "manual",
reason: "Fusion runtime prompt",
idempotencyKey,
payload: {
fusionSessionId: session.sessionId,
prompt,
issueId,
},
});
if (wakeResponse.status === "skipped") {
session.onToolEnd?.("paperclip.run", true, {
issueId,
runStatus: "skipped",
reason: "Paperclip coalesced this wakeup with a recent one (status=skipped).",
});
return;
}
runId = wakeResponse.id;
if (!runId) {
session.onToolEnd?.("paperclip.run", true, {
issueId,
reason: "Paperclip wakeup response missing run id",
});
return;
}
} catch (error) {
if (error instanceof ConflictError) {
this.logger.warn(`Paperclip checkout conflict for issue ${issueId}; continuing: ${error.message}`);
} else {
throw error;
const reason = error instanceof Error ? error.message : String(error);
this.logger.warn(`Paperclip wakeup failed: ${reason}`);
session.onToolEnd?.("paperclip.run", true, { issueId, reason });
return;
}
// ---- Stage 3: stream run events --------------------------------------
const stream = await this.streamRunEvents(session, runId);
// ---- Stage 4: collect final results ----------------------------------
let issueStatus: string | undefined;
let finalText = stream.text;
if (issueId) {
try {
const issue = await getIssue(session.apiUrl, session.apiKey, issueId);
issueStatus = asString(issue.status) ?? undefined;
// Comment fallback: if no streaming text was captured, use the latest
// non-system comment as the visible answer.
if (!finalText) {
const comments = await getIssueComments(session.apiUrl, session.apiKey, issueId);
const latest = pickLatestVisibleComment(comments);
if (latest) finalText = latest;
}
} catch (error) {
// Non-fatal — we still have whatever we streamed.
const reason = error instanceof Error ? error.message : String(error);
this.logger.warn(`Paperclip post-run fetch failed: ${reason}`);
}
}
await invokeHeartbeat(session.apiUrl, session.apiKey, session.agentId);
if (finalText) session.onText?.(finalText);
if (stream.thinking) session.onThinking?.(stream.thinking);
let issue = createdIssue;
let status = pickIssueStatus(issue);
let intervalMs = POLL_INITIAL_INTERVAL_MS;
const startedAt = Date.now();
while (!TERMINAL_STATUSES.has(status) && Date.now() - startedAt < POLL_TIMEOUT_MS) {
await sleep(intervalMs);
issue = await getIssue(session.apiUrl, session.apiKey, issueId);
status = pickIssueStatus(issue);
intervalMs = Math.min(intervalMs * 2, POLL_MAX_INTERVAL_MS);
}
const comments = await getIssueComments(session.apiUrl, session.apiKey, issueId);
const { text, thinking } = collectCommentText(comments);
if (text) {
session.onText?.(text);
}
if (thinking) {
session.onThinking?.(thinking);
}
session.onToolEnd?.("paperclip.issue", false, {
const isError = stream.runStatus === "failed" || stream.runStatus === "timed_out";
session.onToolEnd?.("paperclip.run", isError || stream.timedOutLocally, {
runId,
runStatus: stream.runStatus,
issueId,
status,
issueStatus,
timedOutLocally: stream.timedOutLocally,
deepLink: issueId
? `${session.apiUrl.replace(/\/$/, "")}/issues/${issueId}`
: undefined,
});
}
@@ -192,4 +285,108 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
async dispose(_session: PaperclipSession): Promise<void> {
// no-op: Paperclip manages run/session lifecycle server-side
}
// ---------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------
private async createIssueForPrompt(
session: PaperclipSession,
prompt: string,
): Promise<string> {
const created = await createIssue(session.apiUrl, session.apiKey, session.companyId, {
title: deriveIssueTitle(prompt),
description: buildIssueDescription(session, prompt),
status: "todo",
assigneeAgentId: session.agentId,
...(session.parentIssueId ? { parentId: session.parentIssueId } : {}),
...(session.projectId ? { projectId: session.projectId } : {}),
...(session.goalId ? { goalId: session.goalId } : {}),
});
return pickIssueId(created);
}
private async streamRunEvents(
session: PaperclipSession,
runId: string,
): Promise<{
text: string;
thinking: string;
runStatus: string;
timedOutLocally: boolean;
}> {
const startedAt = Date.now();
let afterSeq = 0;
let interval = session.pollIntervalMs;
let runStatus = "running";
let timedOutLocally = false;
let textBuf = "";
let thinkBuf = "";
while (true) {
let events: RunEvent[] = [];
try {
events = await getRunEvents(
session.apiUrl,
session.apiKey,
runId,
afterSeq,
200,
);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
this.logger.warn(`Paperclip getRunEvents failed: ${reason}`);
}
for (const ev of events) {
if (typeof ev.seq === "number" && ev.seq > afterSeq) afterSeq = ev.seq;
const type = ev.type ?? "";
const payload = ev.payload ?? {};
if (type === "heartbeat.run.status") {
const next = asString(payload.status);
if (next) runStatus = next;
} else if (type === "heartbeat.run.log") {
const chunk = asString(payload.chunk) ?? "";
if (!chunk) continue;
if (payload.stream === "stdout") {
textBuf += chunk;
session.onText?.(chunk);
} else if (payload.stream === "stderr") {
this.logger.warn(`[paperclip:run:${runId}] ${chunk.trimEnd()}`);
} else if (payload.stream === "system") {
// system messages may carry reasoning/thinking-style content
const message = asString(payload.message) ?? chunk;
thinkBuf += (thinkBuf ? "\n" : "") + message;
}
}
// Other event types (adapter.invoke, tool calls) are ignored for v1.
}
if (TERMINAL_RUN_STATUSES.has(runStatus)) break;
if (Date.now() - startedAt > session.runTimeoutMs) {
timedOutLocally = true;
this.logger.warn(
`Paperclip run ${runId} exceeded local runTimeoutMs=${session.runTimeoutMs}; abandoning poll. Run continues server-side.`,
);
break;
}
await sleep(interval);
interval = Math.min(interval * 2, session.pollIntervalMaxMs);
}
return { text: textBuf, thinking: thinkBuf, runStatus, timedOutLocally };
}
}
function pickLatestVisibleComment(
comments: Array<Record<string, unknown>>,
): string | undefined {
for (let i = comments.length - 1; i >= 0; i--) {
const c = comments[i];
const body = asString(c.body)?.trim();
if (body) return body;
}
return undefined;
}