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,75 +1,158 @@
# Paperclip Runtime Plugin
`fusion-plugin-paperclip-runtime` provides the `paperclip` runtime for Fusion agents by calling a running **Paperclip REST API** instance.
Drives a [Paperclip](https://paperclip.ing/) agent (an "employee" in a Paperclip company) via the wakeup + heartbeat-run API. Each Fusion prompt becomes a Paperclip *task*, not a chat completion.
> This plugin no longer delegates to Fusion's internal `@fusion/engine` pi runtime.
## Mental model — read this first
## Runtime Identity
Paperclip is a **control plane** for AI labor. Agents are long-lived employees with budgets, chains of command, and approval gates; *Paperclip itself does not run models* — it dispatches work to adapters (claude_local, codex_local, openclaw, http, …) which run the actual LLM call inside their own heartbeat.
- **Plugin ID:** `fusion-plugin-paperclip-runtime`
- **Runtime ID:** `paperclip`
- **Runtime Name:** `Paperclip Runtime`
This plugin proxies a Fusion conversation through one of those Paperclip agents:
1. (Optionally) creates a Paperclip *issue* with the prompt as its body, assigned to your chosen agent.
2. Calls `POST /api/agents/{id}/wakeup` with `payload: { prompt, fusionSessionId, issueId }` and an idempotency key.
3. Streams `GET /api/heartbeat-runs/{runId}/events`, forwarding `heartbeat.run.log` chunks to the chat UI.
4. On terminal status (`succeeded | failed | cancelled | timed_out`), reads the issue's final state and the agent's closing comment.
**Implications:**
- **Latency is task-shaped** (seconds to minutes), not chat-shaped.
- **Governance applies**: budget caps, approval requirements, audit log all come from Paperclip.
- **A single Paperclip agent can be proxied from many Fusion sessions concurrently.**
## Prerequisites
1. Paperclip is installed and running (default URL: `http://localhost:3100`)
2. Fusion plugin installed:
A running Paperclip server you can reach. For local development:
```bash
fn plugin install ./plugins/fusion-plugin-paperclip-runtime
npm install -g paperclipai
paperclipai onboard # interactive setup
paperclipai run # starts the server (default: http://localhost:3100)
```
## Configuration
Verify the server is up:
The runtime resolves configuration in this priority order:
1. Plugin settings (`ctx.settings`)
2. Environment variables
3. Defaults
| Setting | Env Var | Required | Default |
|---|---|---:|---|
| `apiUrl` | `PAPERCLIP_API_URL` | No | `http://localhost:3100` |
| `apiKey` | `PAPERCLIP_API_KEY` | No | `undefined` |
| `agentId` | `PAPERCLIP_AGENT_ID` | Yes (for session create) | `undefined` |
| `companyId` | `PAPERCLIP_COMPANY_ID` | Yes (for session create) | `undefined` |
### Authentication Modes
- **Bearer token mode:** set `apiKey` / `PAPERCLIP_API_KEY` and requests include `Authorization: Bearer <token>`
- **Local trusted mode:** leave `apiKey` unset; plugin probes `/api/health` without auth and proceeds when allowed by Paperclip deployment mode
## How Runtime Execution Works
For each prompt, the runtime adapter performs:
1. `POST /api/companies/{companyId}/issues` (creates issue in `backlog`, assigned to `agentId`)
2. `POST /api/issues/{issueId}/checkout` (atomic claim; 409 conflicts are logged and execution continues)
3. `POST /api/agents/{agentId}/heartbeat/invoke` (async agent execution)
4. Polls `GET /api/issues/{issueId}` with exponential backoff (2s → 4s → 8s → 10s cap, 120s timeout)
5. Reads output from `GET /api/issues/{issueId}/comments`
6. Emits text/thinking/tool callbacks back to Fusion runtime consumers
The runtime uses Paperclip as the orchestration engine; Fusion receives summarized output via issue comments.
## Runtime Selection in Fusion
Configure an agent with runtime hint `paperclip`:
```json
{
"runtimeConfig": {
"runtimeHint": "paperclip"
}
}
```bash
curl http://localhost:3100/api/health
```
Fusion runtime resolution still falls back to default `pi` runtime if plugin runtime resolution fails.
## Connection modes
The dashboard settings card lets you pick **API** or **CLI** mode:
### API mode (default)
Paste an `apiUrl` and an *agent* `apiKey`. Get a key from the Paperclip UI's agent detail page → "Create API Key" (the full value is shown once). For local-trusted deployments, the key may be omitted.
### CLI mode
Auto-derive the apiUrl from the local `paperclipai` install. The plugin reads `~/.paperclip/instances/default/config.json` and uses that host:port as the apiUrl. No token needs to be pasted into Fusion. For non-local-trusted deployments, you can still set an override `apiKey`.
#### CLI key bootstrap
For authenticated Paperclip deployments (e.g. `paperclip-dev` or any non-`local_trusted` instance), a bearer token is required. The dashboard offers a one-click "✨ Mint API key via paperclipai" button that appears in CLI mode when:
- A connection has been attempted but `available === false` (typically "API key rejected"), AND
- The user has already selected an agent in the agent picker.
The button calls `POST /api/providers/paperclip/cli-mint-key` on the Fusion backend, which spawns:
```
paperclipai agent local-cli <agentRef> --json --no-install-skills --key-name fusion-runtime
```
On success the returned `apiKey` is written into the API key field and a save-prompt toast is shown. On failure (e.g. CLI not authenticated) the toast shows the error and instructs the user to run `paperclipai onboard`.
**Requirement:** The local `paperclipai` CLI must be authenticated (`~/.paperclip/context.json` must have a valid profile). Run `paperclipai onboard` to authenticate if the mint fails.
## Conversation modes
Independent of transport, the `mode` setting controls how prompts map to Paperclip issues:
| Mode | Behavior |
|---|---|
| `rolling-issue` (default) | Creates one Paperclip issue per Fusion session; subsequent prompts add comments. Closest to chat. |
| `issue-per-prompt` | Each prompt creates a new top-level issue. Maximally explicit; clutters the board. |
| `wakeup-only` | No issue side-effects; the prompt is delivered via the wakeup payload only. Requires the agent's prompt template to handle payload-driven wakes. |
## Settings
| Key | Env var | Default | Notes |
|---|---|---|---|
| `transport` | `PAPERCLIP_TRANSPORT` | `api` | `api` or `cli`. |
| `apiUrl` | `PAPERCLIP_API_URL` | `http://localhost:3100` | API mode only. |
| `apiKey` | `PAPERCLIP_API_KEY` | (none) | API mode (and as a CLI-mode override). |
| `cliBinaryPath` | `PAPERCLIPAI_BIN` | `paperclipai` | CLI mode only. |
| `cliConfigPath` | `PAPERCLIP_CLI_CONFIG` | `~/.paperclip/instances/default/config.json` | CLI mode only. |
| `agentId` | `PAPERCLIP_AGENT_ID` | auto-derived from `/api/agents/me` | The Paperclip agent this Fusion runtime proxies. |
| `companyId` | `PAPERCLIP_COMPANY_ID` | auto-derived from `/api/agents/me` | The Paperclip company. |
| `mode` | `PAPERCLIP_RUNTIME_MODE` | `rolling-issue` | One of the conversation modes above. |
| `parentIssueId` | `PAPERCLIP_PARENT_ISSUE_ID` | (none) | Optional issue scoping. |
| `projectId` | `PAPERCLIP_PROJECT_ID` | (none) | Optional. |
| `goalId` | `PAPERCLIP_GOAL_ID` | (none) | Optional. |
| `runTimeoutMs` | `PAPERCLIP_RUN_TIMEOUT_MS` | `600000` | Local cap before Fusion stops polling. The run continues server-side. |
| `pollIntervalMs` | `PAPERCLIP_POLL_INTERVAL_MS` | `500` | Initial poll interval. |
| `pollIntervalMaxMs` | `PAPERCLIP_POLL_INTERVAL_MAX_MS` | `2000` | Max poll interval after exponential backoff. |
Settings precedence: plugin settings → env var → default.
## Public API
```ts
import {
PaperclipRuntimeAdapter,
// REST helpers
agentsMe,
listCompanies,
listCompanyAgents,
// Probes
probePaperclipConnection,
discoverPaperclipCliConfig,
// CLI key minting
mintAgentApiKeyViaCli,
// Types
type PaperclipAgentSummary,
type PaperclipCompanySummary,
type PaperclipConnectionStatus,
type PaperclipCliDiscoveryResult,
type MintCliKeyOptions,
type MintedApiKey,
} from "@fusion-plugin-examples/paperclip-runtime";
```
- `probePaperclipConnection({ apiUrl, apiKey?, timeoutMs? })``{ available, identity?, reason? }`. Powers the dashboard's "✓ Connected as <agent>" badge.
- `listCompanyAgents(apiUrl, apiKey, companyId)` → list of agents in a company. Drives the agent picker.
- `discoverPaperclipCliConfig({ configPath? })``{ ok, apiUrl, deploymentMode? }` from the local `paperclipai` config. Drives CLI-mode auth discovery.
- `mintAgentApiKeyViaCli(opts: MintCliKeyOptions)``Promise<MintedApiKey>`. Spawns `paperclipai agent local-cli <agentRef> --json --no-install-skills` to mint a fresh agent API key. Throws on ENOENT, non-zero exit, or malformed JSON; includes a hint to run `paperclipai onboard` on auth failures.
## Endpoints used (Paperclip side)
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/agents/me` | Identity + auto-derive agentId/companyId. |
| `GET` | `/api/companies` | Company list (board access). |
| `GET` | `/api/companies/{companyId}/agents` | Agent list (agent-key sees its own company). |
| `POST` | `/api/companies/{companyId}/issues` | Issue creation (issue-per-prompt / rolling-issue modes). |
| `POST` | `/api/agents/{agentId}/wakeup` | Trigger a heartbeat run with the Fusion prompt as payload. |
| `GET` | `/api/heartbeat-runs/{runId}/events` | Streaming run log + status. |
| `GET` | `/api/issues/{issueId}` | Final issue state. |
| `GET` | `/api/issues/{issueId}/comments` | Final comment fallback. |
The adapter does **not** call `/api/issues/{id}/checkout` — checkout is the agent's job during its own heartbeat. The adapter does **not** call the legacy `/api/agents/{id}/heartbeat/invoke`.
## Limitations
- **Latency.** Heartbeat runs can take minutes; not a chat-completion drop-in.
- **Single-agent identity per connection.** A Paperclip *agent* API key is scoped to one agent in one company. To proxy several agents, configure several Fusion connections.
- **Run-events schema is partially inferred.** The events endpoint payload shape is documented but not formally schema'd; the client accepts both bare-array and `{ events: [...] }` envelopes defensively.
## Metadata
- **Plugin ID:** `fusion-plugin-paperclip-runtime`
- **Runtime ID:** `paperclip`
- **Package:** `@fusion-plugin-examples/paperclip-runtime`
## Development
```bash
cd plugins/fusion-plugin-paperclip-runtime
pnpm test
pnpm build
pnpm --filter @fusion-plugin-examples/paperclip-runtime test # 46 tests
pnpm --filter @fusion-plugin-examples/paperclip-runtime build
```

View File

@@ -1,12 +1,21 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const { mockProbePaperclipInstance, mockResolvePaperclipConfig, mockAdapterCtor, MockAdapter } = vi.hoisted(() => {
const mockProbe = vi.fn();
const mockResolve = vi.fn((settings?: Record<string, unknown>) => ({
const {
mockProbe,
mockResolve,
mockAdapterCtor,
MockAdapter,
} = vi.hoisted(() => {
const probe = vi.fn();
const resolve = vi.fn((settings?: Record<string, unknown>) => ({
apiUrl: "http://localhost:3100",
apiKey: undefined,
agentId: undefined,
companyId: undefined,
apiKey: undefined as string | undefined,
agentId: undefined as string | undefined,
companyId: undefined as string | undefined,
mode: "rolling-issue" as const,
runTimeoutMs: 600_000,
pollIntervalMs: 500,
pollIntervalMaxMs: 2_000,
...(settings ?? {}),
}));
const adapterCtor = vi.fn();
@@ -17,18 +26,19 @@ const { mockProbePaperclipInstance, mockResolvePaperclipConfig, mockAdapterCtor,
adapterCtor(...args);
}
}
return {
mockProbePaperclipInstance: mockProbe,
mockResolvePaperclipConfig: mockResolve,
mockProbe: probe,
mockResolve: resolve,
mockAdapterCtor: adapterCtor,
MockAdapter: Adapter,
};
});
vi.mock("../pi-module.js", () => ({
probePaperclipInstance: mockProbePaperclipInstance,
resolvePaperclipConfig: mockResolvePaperclipConfig,
vi.mock("../paperclip-client.js", () => ({
probePaperclipConnection: mockProbe,
resolvePaperclipConfig: mockResolve,
// Re-exported by the plugin module — unused inside tests but must resolve.
listCompanyAgents: vi.fn(),
}));
vi.mock("../runtime-adapter.js", () => ({
@@ -40,67 +50,65 @@ import plugin from "../index.js";
describe("paperclip-runtime plugin", () => {
beforeEach(() => {
vi.clearAllMocks();
mockProbePaperclipInstance.mockResolvedValue({ ok: true, deploymentMode: "local_trusted" });
mockProbe.mockResolvedValue({
available: true,
apiUrl: "http://localhost:3100",
probeDurationMs: 5,
identity: {
agentId: "AG-1",
agentName: "Coder",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
},
});
});
it("keeps manifest identity unchanged", () => {
it("manifest identity stays stable", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-paperclip-runtime");
expect(plugin.manifest.runtime?.runtimeId).toBe("paperclip");
expect(plugin.manifest.name).toBe("Paperclip Runtime Plugin");
expect(plugin.runtime?.metadata.runtimeId).toBe("paperclip");
});
it("factory resolves settings and passes config/logger to adapter", async () => {
it("factory passes resolved config + logger to adapter ctor", async () => {
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const ctx = {
settings: {
apiUrl: "http://paperclip.example",
apiKey: "secret",
agentId: "AG-1",
companyId: "CO-1",
agentId: "AG-X",
},
logger,
};
await plugin.runtime!.factory(ctx as any);
expect(mockResolvePaperclipConfig).toHaveBeenCalledWith(ctx.settings);
expect(mockAdapterCtor).toHaveBeenCalledWith(
{
apiUrl: "http://paperclip.example",
apiKey: "secret",
agentId: "AG-1",
companyId: "CO-1",
},
logger,
);
expect(mockResolve).toHaveBeenCalledWith(ctx.settings);
expect(mockAdapterCtor).toHaveBeenCalledTimes(1);
expect(mockAdapterCtor.mock.calls[0][1]).toBe(logger);
});
it("onLoad probes Paperclip and logs success without leaking apiKey", async () => {
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
it("onLoad probes and logs success without leaking apiKey", async () => {
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const ctx = {
settings: {
apiUrl: "http://paperclip.example",
apiKey: "super-secret",
},
settings: { apiUrl: "http://paperclip.example", apiKey: "super-secret" },
logger,
};
await plugin.hooks.onLoad!(ctx as any);
expect(mockProbePaperclipInstance).toHaveBeenCalledWith("http://paperclip.example", "super-secret");
expect(logger.info).toHaveBeenCalledWith(
"Paperclip Runtime Plugin loaded (apiUrl=http://paperclip.example)",
);
await plugin.hooks!.onLoad!(ctx as any);
expect(mockProbe).toHaveBeenCalledWith({
apiUrl: "http://paperclip.example",
apiKey: "super-secret",
});
expect(JSON.stringify(logger.info.mock.calls)).not.toContain("super-secret");
});
it("onLoad logs warning when probe fails", async () => {
mockProbePaperclipInstance.mockResolvedValue({ ok: false, error: "ECONNREFUSED" });
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
await plugin.hooks.onLoad!({ settings: {}, logger } as any);
it("onLoad warns when probe is unavailable", async () => {
mockProbe.mockResolvedValue({
available: false,
apiUrl: "http://localhost:3100",
probeDurationMs: 1,
reason: "ECONNREFUSED",
});
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
await plugin.hooks!.onLoad!({ settings: {}, logger } as any);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("probe failed"));
});
});

View File

@@ -1,18 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ConflictError,
addComment,
checkoutIssue,
agentsMe,
createIssue,
getAgentIdentity,
getIssue,
getIssueComments,
invokeHeartbeat,
listIssues,
probePaperclipInstance,
getRunEvents,
listCompanyAgents,
mintAgentApiKeyViaCli,
probePaperclipConnection,
resolvePaperclipConfig,
updateIssue,
} from "../pi-module.js";
wakeAgent,
} from "../paperclip-client.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@@ -21,7 +23,19 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
describe("paperclip client", () => {
function networkError(): never {
throw new TypeError("fetch failed: ECONNREFUSED");
}
afterEach(() => {
vi.unstubAllGlobals();
});
// ---------------------------------------------------------------------------
// resolvePaperclipConfig
// ---------------------------------------------------------------------------
describe("resolvePaperclipConfig", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
@@ -31,256 +45,447 @@ describe("paperclip client", () => {
afterEach(() => {
process.env = { ...originalEnv };
vi.unstubAllGlobals();
});
describe("resolvePaperclipConfig", () => {
it("prefers plugin settings over env vars", () => {
process.env.PAPERCLIP_API_URL = "http://env-host:3100";
process.env.PAPERCLIP_API_KEY = "env-key";
process.env.PAPERCLIP_AGENT_ID = "env-agent";
process.env.PAPERCLIP_COMPANY_ID = "env-company";
const config = resolvePaperclipConfig({
apiUrl: "http://settings-host:4000/",
apiKey: "settings-key",
agentId: "settings-agent",
companyId: "settings-company",
});
expect(config).toEqual({
apiUrl: "http://settings-host:4000",
apiKey: "settings-key",
agentId: "settings-agent",
companyId: "settings-company",
});
});
it("uses env vars when settings are absent", () => {
process.env.PAPERCLIP_API_URL = "http://env-host:3100/";
process.env.PAPERCLIP_API_KEY = "env-key";
process.env.PAPERCLIP_AGENT_ID = "env-agent";
process.env.PAPERCLIP_COMPANY_ID = "env-company";
expect(resolvePaperclipConfig()).toEqual({
apiUrl: "http://env-host:3100",
apiKey: "env-key",
agentId: "env-agent",
companyId: "env-company",
});
});
it("falls back to hardcoded defaults", () => {
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_AGENT_ID;
delete process.env.PAPERCLIP_COMPANY_ID;
expect(resolvePaperclipConfig()).toEqual({
apiUrl: "http://localhost:3100",
apiKey: undefined,
agentId: undefined,
companyId: undefined,
});
it("prefers plugin settings over env vars", () => {
process.env.PAPERCLIP_API_URL = "http://env-host:3100";
process.env.PAPERCLIP_API_KEY = "env-key";
const config = resolvePaperclipConfig({
apiUrl: "http://settings-host:4000/",
apiKey: "settings-key",
agentId: "AG-set",
companyId: "CO-set",
mode: "issue-per-prompt",
});
expect(config.apiUrl).toBe("http://settings-host:4000");
expect(config.apiKey).toBe("settings-key");
expect(config.agentId).toBe("AG-set");
expect(config.companyId).toBe("CO-set");
expect(config.mode).toBe("issue-per-prompt");
});
it("probePaperclipInstance returns success on health check", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ status: "ok", deploymentMode: "local_trusted" }));
vi.stubGlobal("fetch", fetchMock);
it("falls back to env vars then defaults", () => {
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_AGENT_ID;
delete process.env.PAPERCLIP_COMPANY_ID;
delete process.env.PAPERCLIP_RUNTIME_MODE;
await expect(probePaperclipInstance("http://localhost:3100", "secret")).resolves.toEqual({
ok: true,
deploymentMode: "local_trusted",
});
expect(fetchMock).toHaveBeenCalledWith("http://localhost:3100/api/health", {
method: "GET",
headers: {
Accept: "application/json",
Authorization: "Bearer secret",
},
body: undefined,
});
});
it("probePaperclipInstance returns error on connection failure", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
const result = await probePaperclipInstance("http://localhost:3100");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("network error");
}
});
it("getAgentIdentity returns agent on 200 and structured auth failures", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({ id: "AG-1", name: "Agent", companyId: "CO-1", role: "executor", status: "active" }),
)
.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401))
.mockResolvedValueOnce(jsonResponse({ error: "forbidden" }, 403));
vi.stubGlobal("fetch", fetchMock);
await expect(getAgentIdentity("http://localhost:3100", "key")).resolves.toEqual({
ok: true,
agent: { id: "AG-1", name: "Agent", companyId: "CO-1", role: "executor", status: "active" },
});
await expect(getAgentIdentity("http://localhost:3100")).resolves.toEqual({
ok: false,
reason: "unauthenticated",
});
await expect(getAgentIdentity("http://localhost:3100", "key")).resolves.toEqual({
ok: false,
reason: "not_agent",
});
});
it("createIssue posts issue payload and returns created issue", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "ISS-1", status: "backlog" }));
vi.stubGlobal("fetch", fetchMock);
const result = await createIssue("http://localhost:3100", "key", "COMP-1", {
title: "Title",
description: "Desc",
status: "backlog",
assigneeAgentId: "A-1",
});
expect(result).toEqual({ id: "ISS-1", status: "backlog" });
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:3100/api/companies/COMP-1/issues",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
title: "Title",
description: "Desc",
status: "backlog",
assigneeAgentId: "A-1",
}),
}),
);
});
it("getIssue returns issue object", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "ISS-7", status: "in_progress" }));
vi.stubGlobal("fetch", fetchMock);
await expect(getIssue("http://localhost:3100", "key", "ISS-7")).resolves.toEqual({
id: "ISS-7",
status: "in_progress",
});
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:3100/api/issues/ISS-7",
expect.objectContaining({ method: "GET" }),
);
});
it("checkoutIssue posts agent payload and throws ConflictError on 409", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ id: "ISS-1", status: "in_progress" }))
.mockResolvedValueOnce(jsonResponse({ error: "already checked out" }, 409));
vi.stubGlobal("fetch", fetchMock);
await expect(checkoutIssue("http://localhost:3100", "key", "ISS-1", "AG-1")).resolves.toEqual({
id: "ISS-1",
status: "in_progress",
});
await expect(checkoutIssue("http://localhost:3100", "key", "ISS-1", "AG-1")).rejects.toThrow(
ConflictError,
);
});
it("updateIssue sends run id header when provided", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "ISS-1", status: "done" }));
vi.stubGlobal("fetch", fetchMock);
await updateIssue("http://localhost:3100", "key", "ISS-1", { status: "done" }, "RUN-1");
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:3100/api/issues/ISS-1",
expect.objectContaining({
method: "PATCH",
headers: expect.objectContaining({ "X-Paperclip-Run-Id": "RUN-1" }),
}),
);
});
it("getIssueComments and addComment hit comment endpoints", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse([{ id: "C1", body: "result" }]))
.mockResolvedValueOnce(jsonResponse({ id: "C2" }));
vi.stubGlobal("fetch", fetchMock);
await expect(getIssueComments("http://localhost:3100", "key", "ISS-1")).resolves.toEqual([
{ id: "C1", body: "result" },
]);
await expect(addComment("http://localhost:3100", "key", "ISS-1", "hello", "RUN-2")).resolves.toEqual({
id: "C2",
});
expect(fetchMock).toHaveBeenLastCalledWith(
"http://localhost:3100/api/issues/ISS-1/comments",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ body: "hello" }),
}),
);
});
it("invokeHeartbeat handles queued and skipped responses", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ id: "RUN-1", status: "queued", agentId: "AG-1" }))
.mockResolvedValueOnce(jsonResponse({ status: "skipped" }));
vi.stubGlobal("fetch", fetchMock);
await expect(invokeHeartbeat("http://localhost:3100", "key", "AG-1")).resolves.toEqual({
ok: true,
run: { id: "RUN-1", status: "queued", agentId: "AG-1" },
});
await expect(invokeHeartbeat("http://localhost:3100", "key", "AG-1")).resolves.toEqual({
ok: true,
skipped: true,
});
});
it("listIssues applies query params", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([{ id: "ISS-1" }]));
vi.stubGlobal("fetch", fetchMock);
await expect(
listIssues("http://localhost:3100", "key", "COMP-1", {
status: ["todo", "in_progress"],
assigneeAgentId: "AG-1",
}),
).resolves.toEqual([{ id: "ISS-1" }]);
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:3100/api/companies/COMP-1/issues?status=todo%2Cin_progress&assigneeAgentId=AG-1",
expect.objectContaining({ method: "GET" }),
);
});
it("throws on non-200 and invalid JSON", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(jsonResponse({ error: "boom" }, 500)));
await expect(getIssue("http://localhost:3100", "key", "ISS-1")).rejects.toThrow("Paperclip API 500");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response("not-json", { status: 200, headers: { "Content-Type": "application/json" } }),
),
);
await expect(getIssue("http://localhost:3100", "key", "ISS-1")).rejects.toThrow("invalid JSON");
const config = resolvePaperclipConfig();
expect(config.apiUrl).toBe("http://localhost:3100");
expect(config.apiKey).toBeUndefined();
expect(config.mode).toBe("rolling-issue");
expect(config.runTimeoutMs).toBe(600_000);
});
});
// ---------------------------------------------------------------------------
// agentsMe
// ---------------------------------------------------------------------------
describe("agentsMe", () => {
it("happy path — returns parsed identity", async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
id: "AG-1",
name: "Coder",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
}),
);
vi.stubGlobal("fetch", fetchMock);
const result = await agentsMe("http://localhost:3100", "key");
expect(result).toEqual({
agentId: "AG-1",
agentName: "Coder",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
});
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:3100/api/agents/me",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ Authorization: "Bearer key" }),
}),
);
});
it("no apiKey → no Authorization header", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "AG-1", companyId: "CO-1" }));
vi.stubGlobal("fetch", fetchMock);
await agentsMe("http://localhost:3100");
const call = fetchMock.mock.calls[0][1] as RequestInit;
const headers = call.headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
});
it("401 → throws", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)),
);
await expect(agentsMe("http://localhost:3100", "bad")).rejects.toThrow(
/401/,
);
});
it("connect refused → throws", async () => {
vi.stubGlobal("fetch", vi.fn().mockImplementation(() => networkError()));
await expect(agentsMe("http://localhost:3100", "k")).rejects.toThrow();
});
});
// ---------------------------------------------------------------------------
// createIssue
// ---------------------------------------------------------------------------
describe("createIssue", () => {
it("posts correct body to /companies/{id}/issues", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "ISS-1", status: "todo" }));
vi.stubGlobal("fetch", fetchMock);
await createIssue("http://localhost:3100", "k", "CO-1", {
title: "Fix bug",
description: "details",
status: "todo",
assigneeAgentId: "AG-1",
projectId: "PROJ-1",
});
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("http://localhost:3100/api/companies/CO-1/issues");
expect(opts.method).toBe("POST");
expect(JSON.parse(opts.body as string)).toMatchObject({
title: "Fix bug",
assigneeAgentId: "AG-1",
projectId: "PROJ-1",
});
});
});
// ---------------------------------------------------------------------------
// wakeAgent
// ---------------------------------------------------------------------------
describe("wakeAgent", () => {
it("posts wakeup with idempotency key", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "RUN-1", status: "queued" }));
vi.stubGlobal("fetch", fetchMock);
const result = await wakeAgent("http://localhost:3100", "k", "AG-1", {
source: "on_demand",
triggerDetail: "manual",
idempotencyKey: "session-1:1",
payload: { hello: "world" },
});
expect(result).toEqual({ id: "RUN-1", status: "queued" });
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("http://localhost:3100/api/agents/AG-1/wakeup");
expect(JSON.parse(opts.body as string).idempotencyKey).toBe("session-1:1");
});
});
// ---------------------------------------------------------------------------
// getRunEvents
// ---------------------------------------------------------------------------
describe("getRunEvents", () => {
it("uses afterSeq + limit query params", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse([{ seq: 5, type: "heartbeat.run.status", payload: {} }]));
vi.stubGlobal("fetch", fetchMock);
const result = await getRunEvents("http://localhost:3100", "k", "RUN-1", 4, 50);
expect(result).toEqual([{ seq: 5, type: "heartbeat.run.status", payload: {} }]);
const url = fetchMock.mock.calls[0][0] as string;
expect(url).toContain("/api/heartbeat-runs/RUN-1/events?");
expect(url).toContain("afterSeq=4");
expect(url).toContain("limit=50");
});
it("accepts both bare-array and { events: [...] } envelopes", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(jsonResponse({ events: [{ seq: 1, type: "x", payload: {} }] }))
.mockResolvedValueOnce(jsonResponse([{ seq: 2, type: "y", payload: {} }])),
);
const a = await getRunEvents("http://localhost:3100", "k", "R", 0);
const b = await getRunEvents("http://localhost:3100", "k", "R", 0);
expect(a.map((e) => e.type)).toEqual(["x"]);
expect(b.map((e) => e.type)).toEqual(["y"]);
});
});
// ---------------------------------------------------------------------------
// getIssue / getIssueComments
// ---------------------------------------------------------------------------
describe("getIssue / getIssueComments", () => {
it("getIssue fetches /issues/{id}", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "ISS-1", status: "done" }));
vi.stubGlobal("fetch", fetchMock);
await getIssue("http://localhost:3100", "k", "ISS-1");
expect(fetchMock.mock.calls[0][0]).toBe(
"http://localhost:3100/api/issues/ISS-1",
);
});
it("getIssueComments returns array", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse([{ id: "C-1", body: "hi" }]));
vi.stubGlobal("fetch", fetchMock);
const result = await getIssueComments("http://localhost:3100", "k", "ISS-1");
expect(result).toEqual([{ id: "C-1", body: "hi" }]);
});
});
// ---------------------------------------------------------------------------
// listCompanyAgents
// ---------------------------------------------------------------------------
describe("listCompanyAgents", () => {
it("returns mapped agent summaries", async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse([
{ id: "AG-1", name: "Coder", role: "engineer", companyId: "CO-1", status: "active" },
{ id: "AG-2", name: "Reviewer", role: "reviewer", companyId: "CO-1" },
]),
);
vi.stubGlobal("fetch", fetchMock);
const result = await listCompanyAgents("http://localhost:3100", "k", "CO-1");
expect(result).toEqual([
{ id: "AG-1", name: "Coder", role: "engineer", companyId: "CO-1", status: "active" },
{ id: "AG-2", name: "Reviewer", role: "reviewer", companyId: "CO-1", status: undefined },
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:3100/api/companies/CO-1/agents",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ Authorization: "Bearer k" }),
}),
);
});
it("non-array response → empty list", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ error: "unexpected" })),
);
const result = await listCompanyAgents("http://localhost:3100", "k", "CO-1");
expect(result).toEqual([]);
});
it("skips entries missing id; falls back name=id", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse([
{ id: "AG-1", name: "Ok", companyId: "CO-1" },
{ name: "Missing-id", companyId: "CO-1" },
null,
{ id: "AG-2", companyId: "CO-1" },
]),
),
);
const result = await listCompanyAgents("http://localhost:3100", "k", "CO-1");
expect(result.map((a) => a.id)).toEqual(["AG-1", "AG-2"]);
expect(result[1]).toMatchObject({ id: "AG-2", name: "AG-2" });
});
it("401 → throws", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)),
);
await expect(
listCompanyAgents("http://localhost:3100", "bad", "CO-1"),
).rejects.toThrow(/401/);
});
});
// ---------------------------------------------------------------------------
// probePaperclipConnection
// ---------------------------------------------------------------------------
describe("probePaperclipConnection", () => {
it("200 → available + identity", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse({
id: "AG-1",
name: "Coder",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
}),
),
);
const result = await probePaperclipConnection({
apiUrl: "http://localhost:3100",
apiKey: "k",
});
expect(result.available).toBe(true);
expect(result.identity).toMatchObject({
agentId: "AG-1",
agentName: "Coder",
companyId: "CO-1",
});
});
it("401 → unavailable with reason", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)),
);
const result = await probePaperclipConnection({
apiUrl: "http://localhost:3100",
apiKey: "bad",
});
expect(result.available).toBe(false);
expect(result.reason).toMatch(/rejected|401/i);
});
it("connect refused → unavailable", async () => {
vi.stubGlobal("fetch", vi.fn().mockImplementation(() => networkError()));
const result = await probePaperclipConnection({
apiUrl: "http://localhost:9999",
apiKey: "k",
});
expect(result.available).toBe(false);
expect(result.reason).toMatch(/not reachable|ECONNREFUSED|fetch failed/i);
});
});
// ---------------------------------------------------------------------------
// mintAgentApiKeyViaCli
// ---------------------------------------------------------------------------
describe("mintAgentApiKeyViaCli", () => {
// We mock node:child_process.spawn for each case.
it("success path — parses apiKey from JSON", async () => {
const mockPayload = {
apiKey: "sk-test-mint-key",
apiBase: "http://localhost:3100",
agentId: "AG-1",
companyId: "CO-1",
};
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
const fakeStdout = Readable.from([Buffer.from(JSON.stringify(mockPayload))]);
const fakeStderr = Readable.from([]);
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
const spawnMock = vi.fn().mockReturnValue(fakeChild);
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
// Emit close asynchronously after mock is in place
setImmediate(() => {
fakeChild.emit("close", 0);
});
const result = await mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" });
expect(result.apiKey).toBe("sk-test-mint-key");
expect(result.apiBase).toBe("http://localhost:3100");
expect(result.agentId).toBe("AG-1");
expect(result.companyId).toBe("CO-1");
vi.doUnmock("node:child_process");
});
it("ENOENT on spawn error → throws with install hint", async () => {
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
(fakeChild as unknown as Record<string, unknown>).stdout = Readable.from([]);
(fakeChild as unknown as Record<string, unknown>).stderr = Readable.from([]);
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
const spawnMock = vi.fn().mockReturnValue(fakeChild);
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
setImmediate(() => {
const err = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" });
fakeChild.emit("error", err);
});
await expect(
mintAgentApiKeyViaCli({ agentRef: "my-agent", cliBinaryPath: "/usr/local/bin/paperclipai", companyId: "CO-1" }),
).rejects.toThrow(/binary not found.*npm i -g paperclipai/i);
vi.doUnmock("node:child_process");
});
it("non-zero exit → throws with stderr hint", async () => {
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
const fakeStdout = Readable.from([]);
const fakeStderr = Readable.from([Buffer.from("Error: CLI is not authenticated\n")]);
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
const spawnMock = vi.fn().mockReturnValue(fakeChild);
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
setImmediate(() => {
fakeChild.emit("close", 1);
});
await expect(
mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }),
).rejects.toThrow(/exited 1.*paperclipai onboard/i);
vi.doUnmock("node:child_process");
});
it("malformed JSON output → throws", async () => {
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
const fakeStdout = Readable.from([Buffer.from("not-json-at-all")]);
const fakeStderr = Readable.from([]);
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
const spawnMock = vi.fn().mockReturnValue(fakeChild);
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
setImmediate(() => {
fakeChild.emit("close", 0);
});
await expect(
mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }),
).rejects.toThrow(/non-JSON output/i);
vi.doUnmock("node:child_process");
});
});

View File

@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { probePaperclipConnection } from "../paperclip-client.js";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("probePaperclipConnection", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("connect refused → available: false, reason mentions unreachable", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("ECONNREFUSED")));
const result = await probePaperclipConnection({
apiUrl: "http://localhost:3100",
});
expect(result.available).toBe(false);
expect(result.reason).toContain("not reachable");
expect(result.apiUrl).toBe("http://localhost:3100");
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
});
it("401 → available: false, reason: API key rejected", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)));
const result = await probePaperclipConnection({
apiUrl: "http://paperclip.example",
apiKey: "bad-key",
});
expect(result.available).toBe(false);
expect(result.reason).toBe("API key rejected");
expect(result.identity).toBeUndefined();
});
it("200 → available: true with identity filled", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse({
id: "AG-1",
name: "Coder",
role: "engineer",
companyId: "CO-1",
companyName: "Acme Corp",
}),
),
);
const result = await probePaperclipConnection({
apiUrl: "http://paperclip.example",
apiKey: "valid-key",
});
expect(result.available).toBe(true);
expect(result.identity).toEqual({
agentId: "AG-1",
agentName: "Coder",
role: "engineer",
companyId: "CO-1",
companyName: "Acme Corp",
});
expect(result.probeDurationMs).toBeGreaterThanOrEqual(0);
});
it("uses correct endpoint URL including api prefix", async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({ id: "A", companyId: "C" }),
);
vi.stubGlobal("fetch", fetchMock);
await probePaperclipConnection({ apiUrl: "http://localhost:3100/", apiKey: "k" });
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:3100/api/agents/me");
});
it("passes apiKey as Authorization Bearer header", async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({ id: "A", companyId: "C" }),
);
vi.stubGlobal("fetch", fetchMock);
await probePaperclipConnection({ apiUrl: "http://localhost:3100", apiKey: "my-secret" });
const call = fetchMock.mock.calls[0][1] as RequestInit;
const headers = call.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer my-secret");
});
it("no apiKey → no Authorization header", async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({ id: "A", companyId: "C" }),
);
vi.stubGlobal("fetch", fetchMock);
await probePaperclipConnection({ apiUrl: "http://localhost:3100" });
const call = fetchMock.mock.calls[0][1] as RequestInit;
const headers = call.headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
});
});

View File

@@ -1,307 +1,277 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PaperclipRuntimeAdapter } from "../runtime-adapter.js";
import type { RunEvent } from "../paperclip-client.js";
const {
mockAgentsMe,
mockCreateIssue,
mockCheckoutIssue,
mockInvokeHeartbeat,
mockGetIssue,
mockGetIssueComments,
MockConflictError,
} = vi.hoisted(() => {
class LocalConflictError extends Error {
readonly status = 409;
}
return {
mockCreateIssue: vi.fn(),
mockCheckoutIssue: vi.fn(),
mockInvokeHeartbeat: vi.fn(),
mockGetIssue: vi.fn(),
mockGetIssueComments: vi.fn(),
MockConflictError: LocalConflictError,
};
});
vi.mock("../pi-module.js", () => ({
resolvePaperclipConfig: vi.fn((settings?: Record<string, unknown>) => ({
mockGetRunEvents,
mockWakeAgent,
mockResolveConfig,
} = vi.hoisted(() => ({
mockAgentsMe: vi.fn(),
mockCreateIssue: vi.fn(),
mockGetIssue: vi.fn(),
mockGetIssueComments: vi.fn(),
mockGetRunEvents: vi.fn(),
mockWakeAgent: vi.fn(),
mockResolveConfig: vi.fn((settings?: Record<string, unknown>) => ({
apiUrl: "http://localhost:3100",
apiKey: undefined,
agentId: undefined,
companyId: undefined,
apiKey: undefined as string | undefined,
agentId: undefined as string | undefined,
companyId: undefined as string | undefined,
mode: "rolling-issue" as const,
parentIssueId: undefined as string | undefined,
projectId: undefined as string | undefined,
goalId: undefined as string | undefined,
runTimeoutMs: 60_000,
pollIntervalMs: 1,
pollIntervalMaxMs: 1,
...(settings ?? {}),
})),
createIssue: mockCreateIssue,
checkoutIssue: mockCheckoutIssue,
invokeHeartbeat: mockInvokeHeartbeat,
getIssue: mockGetIssue,
getIssueComments: mockGetIssueComments,
ConflictError: MockConflictError,
}));
describe("PaperclipRuntimeAdapter", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
vi.mock("../paperclip-client.js", () => ({
agentsMe: mockAgentsMe,
createIssue: mockCreateIssue,
getIssue: mockGetIssue,
getIssueComments: mockGetIssueComments,
getRunEvents: mockGetRunEvents,
wakeAgent: mockWakeAgent,
resolvePaperclipConfig: mockResolveConfig,
}));
const baseSessionOpts = {
cwd: "/repo",
systemPrompt: "be helpful",
};
function makeAdapter(config: Record<string, unknown> = {}) {
return new PaperclipRuntimeAdapter(config, {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
});
}
beforeEach(() => {
vi.clearAllMocks();
mockAgentsMe.mockResolvedValue({
agentId: "AG-default",
agentName: "Coder",
role: "engineer",
companyId: "CO-default",
companyName: "Acme",
});
const defaultEvents: RunEvent[] = [
{ seq: 1, type: "heartbeat.run.log", payload: { stream: "stdout", chunk: "hello world" } },
{ seq: 2, type: "heartbeat.run.status", payload: { status: "succeeded" } },
];
mockGetRunEvents.mockResolvedValue(defaultEvents);
mockWakeAgent.mockResolvedValue({ id: "RUN-1", status: "queued" });
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "todo" });
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "done" });
mockGetIssueComments.mockResolvedValue([{ id: "C-1", body: "final answer comment" }]);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("PaperclipRuntimeAdapter — createSession", () => {
it("auto-derives agentId/companyId from /agents/me when missing", async () => {
const adapter = makeAdapter({});
const result = await adapter.createSession({ ...baseSessionOpts });
expect(mockAgentsMe).toHaveBeenCalledTimes(1);
expect(result.session.agentId).toBe("AG-default");
expect(result.session.companyId).toBe("CO-default");
expect(result.sessionFile).toBeUndefined();
});
afterEach(() => {
vi.useRealTimers();
it("uses provided agentId/companyId without calling /agents/me", async () => {
const adapter = makeAdapter({ agentId: "AG-X", companyId: "CO-Y" });
const { session } = await adapter.createSession({ ...baseSessionOpts });
expect(mockAgentsMe).not.toHaveBeenCalled();
expect(session.agentId).toBe("AG-X");
expect(session.companyId).toBe("CO-Y");
});
it("createSession returns configured Paperclip session with undefined sessionFile", async () => {
it("throws if agents/me fails and identity is missing", async () => {
mockAgentsMe.mockRejectedValueOnce(new Error("API key rejected"));
const adapter = makeAdapter({});
await expect(adapter.createSession({ ...baseSessionOpts })).rejects.toThrow(
/could not derive agentId\/companyId/,
);
});
it("normalizes invalid mode to rolling-issue", async () => {
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1", mode: "bogus" });
const { session } = await adapter.createSession({ ...baseSessionOpts });
expect(session.mode).toBe("rolling-issue");
});
});
describe("PaperclipRuntimeAdapter — promptWithFallback (rolling-issue mode)", () => {
it("creates an issue on first prompt, reuses it on second", async () => {
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
const { session } = await adapter.createSession({ ...baseSessionOpts });
await adapter.promptWithFallback(session, "first prompt");
await adapter.promptWithFallback(session, "second prompt");
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
expect(mockWakeAgent).toHaveBeenCalledTimes(2);
expect(session.issueId).toBe("ISS-1");
});
it("forwards stdout chunks to onText", async () => {
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
const onText = vi.fn();
const onThinking = vi.fn();
const onToolStart = vi.fn();
const onToolEnd = vi.fn();
const adapter = new PaperclipRuntimeAdapter({
apiUrl: "http://paperclip.local",
apiKey: "token",
agentId: "AG-1",
companyId: "CO-1",
});
const { session, sessionFile } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "system",
onText,
onThinking,
onToolStart,
onToolEnd,
});
expect(sessionFile).toBeUndefined();
expect(session).toMatchObject({
apiUrl: "http://paperclip.local",
apiKey: "token",
agentId: "AG-1",
companyId: "CO-1",
cwd: "/repo",
systemPrompt: "system",
onText,
onThinking,
onToolStart,
onToolEnd,
});
expect(session.sessionId).toBeTypeOf("string");
const { session } = await adapter.createSession({ ...baseSessionOpts, onText });
await adapter.promptWithFallback(session, "hi");
expect(onText).toHaveBeenCalledWith("hello world");
});
it("createSession throws when required agentId/companyId config is missing", async () => {
const adapter = new PaperclipRuntimeAdapter({ apiUrl: "http://paperclip.local" });
it("idempotency key increments per turn", async () => {
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
const { session } = await adapter.createSession({ ...baseSessionOpts });
await adapter.promptWithFallback(session, "p1");
await adapter.promptWithFallback(session, "p2");
const keys = mockWakeAgent.mock.calls.map((c) => (c[3] as { idempotencyKey: string }).idempotencyKey);
expect(keys[0]).toMatch(/:1$/);
expect(keys[1]).toMatch(/:2$/);
expect(keys[0].split(":")[0]).toBe(keys[1].split(":")[0]);
});
});
await expect(
adapter.createSession({
cwd: "/repo",
systemPrompt: "system",
}),
).rejects.toThrow("missing required config");
describe("PaperclipRuntimeAdapter — issue-per-prompt mode", () => {
it("creates a new issue per prompt", async () => {
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1", mode: "issue-per-prompt" });
const { session } = await adapter.createSession({ ...baseSessionOpts });
await adapter.promptWithFallback(session, "p1");
await adapter.promptWithFallback(session, "p2");
expect(mockCreateIssue).toHaveBeenCalledTimes(2);
});
});
describe("PaperclipRuntimeAdapter — wakeup-only mode", () => {
it("does not create an issue", async () => {
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1", mode: "wakeup-only" });
const { session } = await adapter.createSession({ ...baseSessionOpts });
await adapter.promptWithFallback(session, "p1");
expect(mockCreateIssue).not.toHaveBeenCalled();
expect(mockWakeAgent).toHaveBeenCalledTimes(1);
expect(mockGetIssue).not.toHaveBeenCalled();
});
});
describe("PaperclipRuntimeAdapter — wakeup soft errors", () => {
it("status=skipped → onToolEnd isError=true, no polling", async () => {
mockWakeAgent.mockResolvedValueOnce({ id: "", status: "skipped" });
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
const onToolEnd = vi.fn();
const { session } = await adapter.createSession({ ...baseSessionOpts, onToolEnd });
await adapter.promptWithFallback(session, "p");
expect(onToolEnd).toHaveBeenCalledWith(
"paperclip.run",
true,
expect.objectContaining({ runStatus: "skipped" }),
);
expect(mockGetRunEvents).not.toHaveBeenCalled();
});
it("promptWithFallback creates issue, checks out, invokes heartbeat, polls, and emits output", async () => {
vi.useFakeTimers();
const onText = vi.fn();
const onThinking = vi.fn();
const onToolStart = vi.fn();
it("wakeup throws → onToolEnd isError=true, no polling", async () => {
mockWakeAgent.mockRejectedValueOnce(new Error("nope"));
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
const onToolEnd = vi.fn();
const { session } = await adapter.createSession({ ...baseSessionOpts, onToolEnd });
await adapter.promptWithFallback(session, "p");
expect(onToolEnd).toHaveBeenCalledWith(
"paperclip.run",
true,
expect.objectContaining({ reason: expect.stringContaining("nope") }),
);
expect(mockGetRunEvents).not.toHaveBeenCalled();
});
});
const adapter = new PaperclipRuntimeAdapter({
apiUrl: "http://paperclip.local",
apiKey: "token",
agentId: "AG-1",
companyId: "CO-1",
});
describe("PaperclipRuntimeAdapter — terminal statuses", () => {
it("succeeded → onToolEnd isError=false", async () => {
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
const onToolEnd = vi.fn();
const { session } = await adapter.createSession({ ...baseSessionOpts, onToolEnd });
await adapter.promptWithFallback(session, "p");
expect(onToolEnd).toHaveBeenCalledWith(
"paperclip.run",
false,
expect.objectContaining({ runStatus: "succeeded" }),
);
});
const { session } = await adapter.createSession({
cwd: "/repo",
systemPrompt: "system prompt",
onText,
onThinking,
onToolStart,
onToolEnd,
});
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
mockInvokeHeartbeat.mockResolvedValue({ ok: true, run: { id: "RUN-1", status: "queued" } });
mockGetIssue
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
.mockResolvedValueOnce({ id: "ISS-1", status: "done" });
mockGetIssueComments.mockResolvedValue([
{ id: "C1", body: "Thinking: I should do this" },
{ id: "C2", body: "Completed work." },
it("failed → onToolEnd isError=true", async () => {
mockGetRunEvents.mockResolvedValueOnce([
{ seq: 1, type: "heartbeat.run.status", payload: { status: "failed" } },
]);
const promptPromise = adapter.promptWithFallback(session, "Title line\nBody");
await vi.advanceTimersByTimeAsync(6_000);
await promptPromise;
expect(mockCreateIssue).toHaveBeenCalledWith(
"http://paperclip.local",
"token",
"CO-1",
expect.objectContaining({
title: "Title line",
status: "backlog",
assigneeAgentId: "AG-1",
}),
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
const onToolEnd = vi.fn();
const { session } = await adapter.createSession({ ...baseSessionOpts, onToolEnd });
await adapter.promptWithFallback(session, "p");
expect(onToolEnd).toHaveBeenCalledWith(
"paperclip.run",
true,
expect.objectContaining({ runStatus: "failed" }),
);
expect(mockCheckoutIssue).toHaveBeenCalledWith(
"http://paperclip.local",
"token",
"ISS-1",
"AG-1",
expect.any(String),
);
expect(mockInvokeHeartbeat).toHaveBeenCalledWith("http://paperclip.local", "token", "AG-1");
expect(onText).toHaveBeenCalledWith("Thinking: I should do this\n\nCompleted work.");
expect(onThinking).toHaveBeenCalledWith("I should do this");
expect(onToolStart).toHaveBeenCalledWith(
"paperclip.issue",
expect.objectContaining({ sessionId: expect.any(String) }),
);
expect(onToolEnd).toHaveBeenCalledWith("paperclip.issue", false, {
issueId: "ISS-1",
status: "done",
});
});
it("handles checkout conflicts gracefully and continues", async () => {
vi.useFakeTimers();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const adapter = new PaperclipRuntimeAdapter(
{
apiUrl: "http://paperclip.local",
apiKey: "token",
agentId: "AG-1",
companyId: "CO-1",
},
logger,
);
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
mockCheckoutIssue.mockRejectedValue(new MockConflictError("conflict"));
mockInvokeHeartbeat.mockResolvedValue({ ok: true, skipped: true });
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "done" });
mockGetIssueComments.mockResolvedValue([{ body: "done" }]);
const promise = adapter.promptWithFallback(session, "Prompt");
await vi.advanceTimersByTimeAsync(2_000);
await promise;
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("checkout conflict"));
expect(mockInvokeHeartbeat).toHaveBeenCalled();
});
it("handles heartbeat skipped responses and continues polling", async () => {
vi.useFakeTimers();
const adapter = new PaperclipRuntimeAdapter({
apiUrl: "http://paperclip.local",
apiKey: "token",
it("local timeout → exits with timedOutLocally=true, no throw", async () => {
mockGetRunEvents.mockResolvedValue([
{ seq: 1, type: "heartbeat.run.status", payload: { status: "running" } },
]);
const adapter = makeAdapter({
agentId: "AG-1",
companyId: "CO-1",
runTimeoutMs: 5,
pollIntervalMs: 1,
pollIntervalMaxMs: 1,
});
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
mockInvokeHeartbeat.mockResolvedValue({ ok: true, skipped: true });
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "done" });
mockGetIssueComments.mockResolvedValue([{ body: "done" }]);
const promise = adapter.promptWithFallback(session, "Prompt");
await vi.advanceTimersByTimeAsync(2_000);
await promise;
expect(mockInvokeHeartbeat).toHaveBeenCalled();
expect(mockGetIssue).toHaveBeenCalled();
const onToolEnd = vi.fn();
const { session } = await adapter.createSession({ ...baseSessionOpts, onToolEnd });
await adapter.promptWithFallback(session, "p");
expect(onToolEnd).toHaveBeenCalledWith(
"paperclip.run",
true,
expect.objectContaining({ timedOutLocally: true }),
);
});
});
it("returns output on timeout with whatever comments are available", async () => {
vi.useFakeTimers();
describe("PaperclipRuntimeAdapter — comment fallback", () => {
it("uses latest comment as text when no streamed stdout", async () => {
mockGetRunEvents.mockResolvedValueOnce([
{ seq: 1, type: "heartbeat.run.status", payload: { status: "succeeded" } },
]);
mockGetIssueComments.mockResolvedValueOnce([
{ id: "C-old", body: "older" },
{ id: "C-latest", body: "latest answer" },
]);
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
const onText = vi.fn();
const adapter = new PaperclipRuntimeAdapter({
apiUrl: "http://paperclip.local",
apiKey: "token",
agentId: "AG-1",
companyId: "CO-1",
});
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system", onText });
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
mockInvokeHeartbeat.mockResolvedValue({ ok: true, run: { id: "RUN-1", status: "queued" } });
mockGetIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
mockGetIssueComments.mockResolvedValue([{ body: "partial result" }]);
const promise = adapter.promptWithFallback(session, "Prompt");
await vi.advanceTimersByTimeAsync(130_000);
await promise;
expect(onText).toHaveBeenCalledWith("partial result");
});
it("uses exponential backoff intervals while polling", async () => {
vi.useFakeTimers();
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const adapter = new PaperclipRuntimeAdapter({
apiUrl: "http://paperclip.local",
apiKey: "token",
agentId: "AG-1",
companyId: "CO-1",
});
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
mockCreateIssue.mockResolvedValue({ id: "ISS-1", status: "backlog" });
mockCheckoutIssue.mockResolvedValue({ id: "ISS-1", status: "in_progress" });
mockInvokeHeartbeat.mockResolvedValue({ ok: true, run: { id: "RUN-1", status: "queued" } });
mockGetIssue
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
.mockResolvedValueOnce({ id: "ISS-1", status: "in_progress" })
.mockResolvedValueOnce({ id: "ISS-1", status: "done" });
mockGetIssueComments.mockResolvedValue([{ body: "done" }]);
const promise = adapter.promptWithFallback(session, "Prompt");
await vi.advanceTimersByTimeAsync(2_000 + 4_000 + 8_000 + 10_000 + 10_000);
await promise;
const timeoutDurations = timeoutSpy.mock.calls.map((call) => call[1]).filter((value) => typeof value === "number");
expect(timeoutDurations).toEqual(expect.arrayContaining([2_000, 4_000, 8_000, 10_000]));
const { session } = await adapter.createSession({ ...baseSessionOpts, onText });
await adapter.promptWithFallback(session, "p");
expect(onText).toHaveBeenCalledWith("latest answer");
});
});
describe("PaperclipRuntimeAdapter — describeModel/dispose", () => {
it("describeModel returns paperclip/<agentId>", async () => {
const adapter = new PaperclipRuntimeAdapter({
apiUrl: "http://paperclip.local",
agentId: "AG-1",
companyId: "CO-1",
});
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
expect(adapter.describeModel(session)).toBe("paperclip/AG-1");
const adapter = makeAdapter({ agentId: "AG-XYZ", companyId: "CO-1" });
const { session } = await adapter.createSession({ ...baseSessionOpts });
expect(adapter.describeModel(session)).toBe("paperclip/AG-XYZ");
});
it("dispose is a no-op", async () => {
const adapter = new PaperclipRuntimeAdapter({
apiUrl: "http://paperclip.local",
agentId: "AG-1",
companyId: "CO-1",
});
const { session } = await adapter.createSession({ cwd: "/repo", systemPrompt: "system" });
expect(typeof session.dispose).toBe("function");
expect(() => session.dispose?.()).not.toThrow();
await expect(adapter.dispose(session)).resolves.toBeUndefined();
const adapter = makeAdapter({ agentId: "AG-1", companyId: "CO-1" });
const { session } = await adapter.createSession({ ...baseSessionOpts });
await expect(adapter.dispose!(session)).resolves.toBeUndefined();
});
});

View File

@@ -1,5 +1,8 @@
import { definePlugin } from "@fusion/plugin-sdk";
import { probePaperclipInstance, resolvePaperclipConfig } from "./pi-module.js";
import {
probePaperclipConnection,
resolvePaperclipConfig,
} from "./paperclip-client.js";
import { PaperclipRuntimeAdapter } from "./runtime-adapter.js";
import type {
FusionPlugin,
@@ -7,20 +10,46 @@ import type {
RuntimeLogger,
} from "./types.js";
// Public exports — consumed by the dashboard probe façade and tests.
export type {
PaperclipAgentSummary,
PaperclipCliDiscovery,
PaperclipCliDiscoveryResult,
PaperclipCompanySummary,
PaperclipConnectionStatus,
} from "./paperclip-client.js";
export {
agentsMe,
discoverPaperclipCliConfig,
listCompanies,
listCompanyAgents,
mintAgentApiKeyViaCli,
probePaperclipConnection,
} from "./paperclip-client.js";
export type { MintCliKeyOptions, MintedApiKey } from "./paperclip-client.js";
export { PaperclipRuntimeAdapter } from "./runtime-adapter.js";
function getSettingsConfig(settings: unknown) {
return resolvePaperclipConfig((settings ?? {}) as Record<string, unknown>);
}
async function paperclipRuntimeFactory(ctx: { settings?: unknown; logger?: RuntimeLogger }): Promise<unknown> {
async function paperclipRuntimeFactory(ctx: {
settings?: unknown;
logger?: RuntimeLogger;
}): Promise<unknown> {
const config = getSettingsConfig(ctx.settings);
return new PaperclipRuntimeAdapter(config, ctx.logger);
// resolvePaperclipConfig returns `mode: string`; the adapter narrows it.
return new PaperclipRuntimeAdapter(
config as unknown as Record<string, unknown>,
ctx.logger,
);
}
const paperclipRuntime: PluginRuntimeRegistration = {
metadata: {
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Paperclip-backed AI session via Paperclip REST API",
description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API",
version: "1.0.0",
},
factory: paperclipRuntimeFactory,
@@ -31,14 +60,14 @@ const plugin: FusionPlugin = definePlugin({
id: "fusion-plugin-paperclip-runtime",
name: "Paperclip Runtime Plugin",
version: "1.0.0",
description: "Provides Paperclip runtime for Fusion AI agents",
description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API",
author: "Fusion Team",
homepage: "https://github.com/gsxdsm/fusion",
homepage: "https://paperclip.ing/",
fusionVersion: ">=0.1.0",
runtime: {
runtimeId: "paperclip",
name: "Paperclip Runtime",
description: "Paperclip-backed AI session via Paperclip REST API",
description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API",
version: "1.0.0",
},
},
@@ -49,15 +78,26 @@ const plugin: FusionPlugin = definePlugin({
const config = getSettingsConfig(ctx.settings);
ctx.logger.info(`Paperclip Runtime Plugin loaded (apiUrl=${config.apiUrl})`);
const probe = await probePaperclipInstance(config.apiUrl, config.apiKey);
if (probe.ok) {
ctx.logger.info(
`Paperclip probe succeeded (deploymentMode=${probe.deploymentMode ?? "unknown"})`,
);
return;
// Best-effort connectivity probe; failures are warnings, not errors.
try {
const status = await probePaperclipConnection({
apiUrl: config.apiUrl,
apiKey: config.apiKey,
});
if (status.available) {
const ident = status.identity;
ctx.logger.info(
ident
? `Paperclip reachable as ${ident.agentName} (${ident.role ?? "agent"}) at ${ident.companyName ?? ident.companyId}`
: `Paperclip reachable at ${config.apiUrl}`,
);
} else {
ctx.logger.warn(`Paperclip probe failed: ${status.reason ?? "unknown"}`);
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
ctx.logger.warn(`Paperclip probe threw: ${reason}`);
}
ctx.logger.warn(`Paperclip probe failed: ${probe.error}`);
},
},
});

View File

@@ -0,0 +1,939 @@
/**
* Paperclip REST API client.
*
* Low-level HTTP helpers for the Paperclip control-plane API.
* This module intentionally does not depend on @fusion/engine or any Fusion
* internals — it is a pure HTTP client.
*/
// ---------------------------------------------------------------------------
// Public error types
// ---------------------------------------------------------------------------
/**
* Thrown when a Paperclip API call returns HTTP 409 Conflict.
* Usually means another agent already owns the issue being checked out.
*/
export class ConflictError extends Error {
readonly status = 409;
constructor(message: string) {
super(message);
this.name = "ConflictError";
}
}
/**
* Thrown when a wakeup call is rejected with HTTP 202 + `status: "skipped"`.
* This is a soft error — the agent is already running and the wakeup was
* coalesced server-side. No polling should follow.
*/
export class WakeupSkippedError extends Error {
constructor(public readonly runId?: string) {
super("Paperclip wakeup was coalesced/skipped — agent already active");
this.name = "WakeupSkippedError";
}
}
// ---------------------------------------------------------------------------
// Shared data shapes
// ---------------------------------------------------------------------------
export interface AgentsMeResponse {
agentId: string;
agentName: string;
role?: string;
companyId: string;
companyName?: string;
}
export interface CreateIssueBody {
title: string;
description: string;
status: string;
assigneeAgentId: string;
parentId?: string;
projectId?: string;
goalId?: string;
}
export interface WakeAgentBody {
source: "on_demand" | "timer" | "assignment" | "automation";
triggerDetail?: "manual" | "ping" | "callback" | "system";
reason?: string;
idempotencyKey?: string;
payload?: Record<string, unknown>;
}
export interface WakeAgentResponse {
id: string;
status: string; // "queued" | "running" | "skipped" | ...
}
/**
* A single event from GET /api/heartbeat-runs/{runId}/events.
*
* The exact payload shape is not fully documented; we treat it defensively.
*/
export interface RunEvent {
id?: string | number;
seq: number;
type: string; // e.g. "heartbeat.run.log", "heartbeat.run.status", "adapter.invoke"
payload?: {
stream?: "stdout" | "stderr" | "system";
chunk?: string;
message?: string;
status?: string;
[key: string]: unknown;
};
createdAt?: string;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function normalizeApiUrl(url: string): string {
return url.replace(/\/+$/, "");
}
function buildUrl(apiUrl: string, path: string, query?: URLSearchParams): string {
const base = normalizeApiUrl(apiUrl);
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const qs = query && query.size > 0 ? `?${query.toString()}` : "";
return `${base}/api${normalizedPath}${qs}`;
}
function buildHeaders(apiKey?: string, extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/json",
...extra,
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
return headers;
}
async function parseJsonBody(response: Response): Promise<{ value: unknown; raw: string }> {
const raw = await response.text();
if (raw.trim() === "") {
return { value: undefined, raw };
}
try {
return { value: JSON.parse(raw), raw };
} catch {
throw new Error(
`Paperclip API ${response.status} ${response.statusText}: invalid JSON response body`,
);
}
}
function toErrorMessage(status: number, statusText: string, body: unknown, raw: string): string {
if (body && typeof body === "object") {
const b = body as Record<string, unknown>;
if (typeof b.error === "string") return b.error;
if (typeof b.message === "string") return b.message;
}
if (raw.trim() !== "") return raw.slice(0, 200).trim();
return `${status} ${statusText}`.trim();
}
async function request<T>(
apiUrl: string,
path: string,
options?: {
method?: string;
apiKey?: string;
body?: unknown;
query?: URLSearchParams;
},
): Promise<T> {
const method = options?.method ?? "GET";
const url = buildUrl(apiUrl, path, options?.query);
const headers = buildHeaders(options?.apiKey);
let bodyStr: string | undefined;
if (options && "body" in options && options.body !== undefined) {
headers["Content-Type"] = "application/json";
bodyStr = JSON.stringify(options.body);
}
let response: Response;
try {
response = await fetch(url, { method, headers, body: bodyStr });
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Paperclip API network error (${method} ${url}): ${reason}`);
}
const { value, raw } = await parseJsonBody(response);
if (!response.ok) {
const msg = toErrorMessage(response.status, response.statusText, value, raw);
const full = `Paperclip API ${response.status} (${method} ${path}): ${msg}`;
if (response.status === 409) throw new ConflictError(full);
throw new Error(full);
}
return value as T;
}
// ---------------------------------------------------------------------------
// Config resolution
// ---------------------------------------------------------------------------
function getSettingString(settings: Record<string, unknown> | undefined, key: string): string | undefined {
const v = settings?.[key];
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
}
export interface PaperclipClientConfig {
apiUrl: string;
apiKey?: string;
agentId?: string;
companyId?: string;
mode?: string;
transport?: "api" | "cli";
cliBinaryPath?: string;
cliConfigPath?: string;
parentIssueId?: string;
projectId?: string;
goalId?: string;
runTimeoutMs?: number;
pollIntervalMs?: number;
pollIntervalMaxMs?: number;
}
/**
* Resolves the Paperclip client config from plugin settings and environment
* variables, with the following precedence:
* plugin settings > environment variables > built-in defaults
*/
export function resolvePaperclipConfig(settings?: Record<string, unknown>): PaperclipClientConfig {
const apiUrl = normalizeApiUrl(
getSettingString(settings, "apiUrl") ??
process.env.PAPERCLIP_API_URL?.trim() ??
"http://localhost:3100",
);
const apiKey =
getSettingString(settings, "apiKey") ??
(process.env.PAPERCLIP_API_KEY?.trim() || undefined);
const agentId =
getSettingString(settings, "agentId") ??
process.env.PAPERCLIP_AGENT_ID?.trim() ??
undefined;
const companyId =
getSettingString(settings, "companyId") ??
process.env.PAPERCLIP_COMPANY_ID?.trim() ??
undefined;
const mode =
getSettingString(settings, "mode") ??
process.env.PAPERCLIP_RUNTIME_MODE?.trim() ??
"rolling-issue";
const transportRaw =
getSettingString(settings, "transport") ??
process.env.PAPERCLIP_TRANSPORT?.trim() ??
"api";
const transport: "api" | "cli" = transportRaw === "cli" ? "cli" : "api";
const cliBinaryPath =
getSettingString(settings, "cliBinaryPath") ??
process.env.PAPERCLIPAI_BIN?.trim() ??
"paperclipai";
const cliConfigPath =
getSettingString(settings, "cliConfigPath") ??
process.env.PAPERCLIP_CLI_CONFIG?.trim() ??
undefined;
const parentIssueId =
getSettingString(settings, "parentIssueId") ??
process.env.PAPERCLIP_PARENT_ISSUE_ID?.trim() ??
undefined;
const projectId =
getSettingString(settings, "projectId") ??
process.env.PAPERCLIP_PROJECT_ID?.trim() ??
undefined;
const goalId =
getSettingString(settings, "goalId") ??
process.env.PAPERCLIP_GOAL_ID?.trim() ??
undefined;
const runTimeoutMs = parseInt(
getSettingString(settings, "runTimeoutMs") ??
process.env.PAPERCLIP_RUN_TIMEOUT_MS ??
"600000",
10,
);
const pollIntervalMs = parseInt(
getSettingString(settings, "pollIntervalMs") ??
process.env.PAPERCLIP_POLL_INTERVAL_MS ??
"500",
10,
);
const pollIntervalMaxMs = parseInt(
getSettingString(settings, "pollIntervalMaxMs") ??
process.env.PAPERCLIP_POLL_INTERVAL_MAX_MS ??
"2000",
10,
);
return {
apiUrl,
apiKey,
agentId,
companyId,
mode,
transport,
cliBinaryPath,
cliConfigPath,
parentIssueId,
projectId,
goalId,
runTimeoutMs: isFinite(runTimeoutMs) ? runTimeoutMs : 600_000,
pollIntervalMs: isFinite(pollIntervalMs) ? pollIntervalMs : 500,
pollIntervalMaxMs: isFinite(pollIntervalMaxMs) ? pollIntervalMaxMs : 2_000,
};
}
// ---------------------------------------------------------------------------
// CLI-mode auth discovery
// ---------------------------------------------------------------------------
/**
* Result of discovering Paperclip auth from a local `paperclipai` install.
*/
export interface PaperclipCliDiscovery {
/** Resolved API URL, e.g. "http://127.0.0.1:3100". */
apiUrl: string;
/** API key, or undefined for local-trusted deployments. */
apiKey?: string;
/** Path to the config file the discovery read from. */
configPath: string;
/** Deployment mode reported by the config (e.g. "local_trusted", "cloud"). */
deploymentMode?: string;
}
export interface PaperclipCliDiscoveryError {
ok: false;
reason: string;
/** The path we tried to read, if any. */
configPath?: string;
}
export type PaperclipCliDiscoveryResult =
| ({ ok: true } & PaperclipCliDiscovery)
| PaperclipCliDiscoveryError;
/**
* Attempt to discover paperclipai's apiUrl + apiKey from its local config file.
*
* Reads `~/.paperclip/instances/default/config.json` by default. When
* `deploymentMode === "local_trusted"`, no apiKey is needed (trusted-localhost
* mode skips auth). For other modes, an apiKey must still be configured by the
* user; this function reports `ok: true` with the URL but `apiKey: undefined`,
* leaving the caller to decide whether to fall back to API-mode auth.
*
* Why a separate function: keeps the discovery logic testable and lets callers
* distinguish "no paperclipai install" from "install present but config absent".
*/
export async function discoverPaperclipCliConfig(
opts: { configPath?: string } = {},
): Promise<PaperclipCliDiscoveryResult> {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const os = await import("node:os");
const configPath =
opts.configPath ??
path.join(os.homedir(), ".paperclip", "instances", "default", "config.json");
let raw: string;
try {
raw = await fs.readFile(configPath, "utf-8");
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return {
ok: false,
configPath,
reason:
code === "ENOENT"
? `Paperclip CLI config not found at ${configPath}. Install paperclipai and run \`paperclipai onboard\`, or use API mode.`
: `Could not read ${configPath}: ${(error as Error).message}`,
};
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch {
return { ok: false, configPath, reason: `Invalid JSON in ${configPath}` };
}
const server = (parsed.server ?? {}) as Record<string, unknown>;
const host = typeof server.host === "string" ? server.host : "127.0.0.1";
const port =
typeof server.port === "number"
? server.port
: Number(server.port ?? 3100) || 3100;
const deploymentMode =
typeof server.deploymentMode === "string" ? server.deploymentMode : undefined;
return {
ok: true,
apiUrl: `http://${host}:${port}`,
apiKey: undefined,
configPath,
deploymentMode,
};
}
// ---------------------------------------------------------------------------
// Paperclip REST helpers
// ---------------------------------------------------------------------------
/**
* Returns the identity of the API key owner.
* Used to auto-derive agentId and companyId when they are not configured.
*
* @throws if the server returns an unexpected error (non-401/403/404/ECONNREFUSED).
*/
export async function agentsMe(
apiUrl: string,
apiKey?: string,
): Promise<AgentsMeResponse> {
const raw = await request<Record<string, unknown>>(apiUrl, "/agents/me", { apiKey });
const id = typeof raw.id === "string" ? raw.id : undefined;
const name = typeof raw.name === "string" ? raw.name : undefined;
const role = typeof raw.role === "string" ? raw.role : undefined;
const cId = typeof raw.companyId === "string" ? raw.companyId : undefined;
const cName = typeof raw.companyName === "string" ? raw.companyName : undefined;
if (!id || !cId) {
throw new Error("Paperclip /api/agents/me returned a response missing `id` or `companyId`");
}
return { agentId: id, agentName: name ?? id, role, companyId: cId, companyName: cName };
}
/**
* Creates a new Paperclip issue and returns its `id`.
*/
export async function createIssue(
apiUrl: string,
apiKey: string | undefined,
companyId: string,
body: CreateIssueBody,
): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>(apiUrl, `/companies/${companyId}/issues`, {
method: "POST",
apiKey,
body,
});
}
/**
* Fetches a single issue by ID.
*/
export async function getIssue(
apiUrl: string,
apiKey: string | undefined,
issueId: string,
): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}`, { apiKey });
}
/**
* Fetches comments on an issue.
*/
export async function getIssueComments(
apiUrl: string,
apiKey: string | undefined,
issueId: string,
): Promise<Array<Record<string, unknown>>> {
return request<Array<Record<string, unknown>>>(apiUrl, `/issues/${issueId}/comments`, { apiKey });
}
/**
* Triggers a wakeup for an agent. Returns the created run (or a skipped
* response).
*
* Callers should check `response.status === "skipped"` and handle accordingly;
* this function does NOT throw for skipped — it returns the raw response.
*/
export async function wakeAgent(
apiUrl: string,
apiKey: string | undefined,
agentId: string,
body: WakeAgentBody,
): Promise<WakeAgentResponse> {
return request<WakeAgentResponse>(apiUrl, `/agents/${agentId}/wakeup`, {
method: "POST",
apiKey,
body,
});
}
/**
* Fetches lightweight run events, starting after `afterSeq`.
*
* The API endpoint is `GET /api/heartbeat-runs/{runId}/events?afterSeq=N&limit=L`.
*/
export async function getRunEvents(
apiUrl: string,
apiKey: string | undefined,
runId: string,
afterSeq: number,
limit = 100,
): Promise<RunEvent[]> {
const query = new URLSearchParams({
afterSeq: String(afterSeq),
limit: String(limit),
});
const result = await request<unknown>(apiUrl, `/heartbeat-runs/${runId}/events`, {
apiKey,
query,
});
// Accept both { events: RunEvent[] } and RunEvent[] response shapes
if (Array.isArray(result)) return result as RunEvent[];
const r = result as Record<string, unknown>;
if (Array.isArray(r.events)) return r.events as RunEvent[];
return [];
}
// ---------------------------------------------------------------------------
// Company discovery
// ---------------------------------------------------------------------------
export interface PaperclipCompanySummary {
id: string;
name: string;
/** Optional URL slug used in deep-links; not always present. */
urlKey?: string;
}
/**
* Lists companies visible to the bearer. Backed by `GET /api/companies`.
*
* For local-trusted deployments without auth, returns every company on the
* instance. For agent-key-scoped requests, returns just the agent's company.
*/
export async function listCompanies(
apiUrl: string,
apiKey: string | undefined,
): Promise<PaperclipCompanySummary[]> {
const raw = await request<unknown>(apiUrl, "/companies", { apiKey });
if (!Array.isArray(raw)) return [];
const out: PaperclipCompanySummary[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const r = entry as Record<string, unknown>;
const id = typeof r.id === "string" ? r.id : undefined;
if (!id) continue;
const name = typeof r.name === "string" ? r.name : id;
const urlKey =
typeof r.urlKey === "string"
? r.urlKey
: typeof r.slug === "string"
? r.slug
: undefined;
out.push({ id, name, urlKey });
}
return out;
}
// ---------------------------------------------------------------------------
// Agent discovery (lets the dashboard show a dropdown of real agents)
// ---------------------------------------------------------------------------
export interface PaperclipAgentSummary {
id: string;
name: string;
role?: string;
companyId: string;
status?: string;
/** True when this is the agent that owns the current API key. */
isCurrent?: boolean;
}
/**
* Lists all agents visible to the API key in the given company.
*
* Backed by `GET /api/companies/{companyId}/agents`. An agent API key can
* see siblings in its own company (the server enforces company access via
* `assertCompanyAccess`). Returned objects are the full agent records;
* we project them down to the fields the dashboard actually renders.
*/
export async function listCompanyAgents(
apiUrl: string,
apiKey: string | undefined,
companyId: string,
): Promise<PaperclipAgentSummary[]> {
const raw = await request<unknown>(apiUrl, `/companies/${companyId}/agents`, { apiKey });
if (!Array.isArray(raw)) return [];
const out: PaperclipAgentSummary[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const r = entry as Record<string, unknown>;
const id = typeof r.id === "string" ? r.id : undefined;
if (!id) continue;
const name = typeof r.name === "string" ? r.name : id;
const role = typeof r.role === "string" ? r.role : undefined;
const cId = typeof r.companyId === "string" ? r.companyId : companyId;
const status = typeof r.status === "string" ? r.status : undefined;
out.push({ id, name, role, companyId: cId, status });
}
return out;
}
// ---------------------------------------------------------------------------
// Probe function (public introspection)
// ---------------------------------------------------------------------------
export interface PaperclipConnectionStatus {
available: boolean;
apiUrl: string;
identity?: {
agentId: string;
agentName: string;
role?: string;
companyId: string;
companyName?: string;
};
/** Human-readable failure reason when `available === false`. */
reason?: string;
probeDurationMs: number;
}
/**
* Probes a Paperclip server at `apiUrl` using `GET /api/agents/me`.
*
* - `200` with valid body → `{ available: true, identity: ... }`
* - `401` / `403` → `{ available: false, reason: "API key rejected" }`
* - `404` / network error → `{ available: false, reason: "Paperclip server not reachable at <url>" }`
* - Anything else → `{ available: false, reason: "<status> <first 200 chars of body>" }`
*/
export async function probePaperclipConnection(opts: {
apiUrl: string;
apiKey?: string;
timeoutMs?: number;
}): Promise<PaperclipConnectionStatus> {
const { apiKey, timeoutMs = 5_000 } = opts;
const apiUrl = normalizeApiUrl(opts.apiUrl);
const url = buildUrl(apiUrl, "/agents/me");
const started = Date.now();
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: buildHeaders(apiKey),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
return {
available: false,
apiUrl,
reason: `Paperclip server not reachable at ${apiUrl}: ${reason}`,
probeDurationMs: Date.now() - started,
};
}
const probeDurationMs = Date.now() - started;
if (response.status === 401 || response.status === 403) {
return { available: false, apiUrl, reason: "API key rejected", probeDurationMs };
}
if (response.status === 404) {
return {
available: false,
apiUrl,
reason: `Paperclip server not reachable at ${apiUrl}: 404 Not Found`,
probeDurationMs,
};
}
if (!response.ok) {
let body = "";
try {
body = (await response.text()).slice(0, 200);
} catch {
// ignore
}
return {
available: false,
apiUrl,
reason: `${response.status} ${response.statusText}: ${body}`.trim(),
probeDurationMs,
};
}
let identity: PaperclipConnectionStatus["identity"];
try {
const { value: raw } = await parseJsonBody(response);
const r = raw as Record<string, unknown>;
const agentId = typeof r.id === "string" ? r.id : "";
const companyId = typeof r.companyId === "string" ? r.companyId : "";
identity = {
agentId,
agentName: typeof r.name === "string" ? r.name : agentId,
role: typeof r.role === "string" ? r.role : undefined,
companyId,
companyName: typeof r.companyName === "string" ? r.companyName : undefined,
};
} catch {
return {
available: false,
apiUrl,
reason: "Paperclip server responded 200 but returned invalid JSON",
probeDurationMs,
};
}
return { available: true, apiUrl, identity, probeDurationMs };
}
// ---------------------------------------------------------------------------
// CLI key minting
// ---------------------------------------------------------------------------
export interface MintCliKeyOptions {
/** Path to paperclipai binary or "paperclipai" on PATH. */
cliBinaryPath?: string;
/** Required: the Paperclip agent ID or shortname. */
agentRef: string;
/** Required: the Paperclip company ID (paperclipai's local-cli requires -C). */
companyId: string;
/** Optional: name for the new key (default "fusion-runtime"). */
keyName?: string;
/** Optional: override the paperclipai config path. */
configPath?: string;
/** Optional: override the paperclipai data dir. */
dataDir?: string;
/** Hard-kill timeout for the spawn (default 30_000 ms). */
cliTimeoutMs?: number;
}
export interface MintedApiKey {
apiKey: string;
apiBase?: string;
agentId?: string;
companyId?: string;
/** Raw JSON the CLI emitted, for diagnostics. */
raw?: unknown;
}
/** Strip ANSI escape sequences from a string. */
function stripAnsi(str: string): string {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1B\[[0-9;]*[mGKJHFABCDSTsu]/g, "");
}
/**
* Mints an agent API key by spawning `paperclipai agent local-cli <agentRef> --json --no-install-skills`.
*
* Requires the local `paperclipai` CLI to be onboarded (`~/.paperclip/context.json`
* must have an authenticated profile). If not onboarded, throws with a hint to
* run `paperclipai onboard`.
*/
export async function mintAgentApiKeyViaCli(opts: MintCliKeyOptions): Promise<MintedApiKey> {
const { spawn } = await import("node:child_process");
const bin = opts.cliBinaryPath ?? "paperclipai";
const args: string[] = [
"agent",
"local-cli",
opts.agentRef,
"--json",
"--no-install-skills",
"--key-name",
opts.keyName ?? "fusion-runtime",
];
if (opts.companyId) {
args.push("--company-id", opts.companyId);
}
if (opts.configPath) {
args.push("--config", opts.configPath);
}
if (opts.dataDir) {
args.push("--data-dir", opts.dataDir);
}
const timeoutMs = opts.cliTimeoutMs ?? 30_000;
return new Promise<MintedApiKey>((resolve, reject) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
reject(
new Error(
`paperclipai binary not found at ${bin}; install via \`npm i -g paperclipai\``,
),
);
} else {
reject(err instanceof Error ? err : new Error(String(err)));
}
return;
}
const stdoutChunks: Buffer[] = [];
const stderrLines: string[] = [];
let killed = false;
const timer = setTimeout(() => {
killed = true;
child.kill("SIGKILL");
reject(new Error(`paperclipai agent local-cli timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout?.on("data", (chunk: Buffer) => {
stdoutChunks.push(chunk);
});
child.stderr?.on("data", (chunk: Buffer) => {
const lines = chunk.toString("utf-8").split("\n");
for (const line of lines) {
const stripped = stripAnsi(line).trim();
if (stripped) stderrLines.push(stripped);
}
});
child.on("error", (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
if (err.code === "ENOENT") {
reject(
new Error(
`paperclipai binary not found at ${bin}; install via \`npm i -g paperclipai\``,
),
);
} else {
reject(err);
}
});
child.on("close", (code: number | null) => {
clearTimeout(timer);
if (killed) return;
const rawStdout = Buffer.concat(stdoutChunks).toString("utf-8");
const cleanedStdout = stripAnsi(rawStdout).trim();
if (code !== 0) {
const lastStderrLine = stderrLines.filter(Boolean).pop() ?? "";
const hint = "run `paperclipai onboard` to authenticate the CLI";
const msg = lastStderrLine
? `paperclipai agent local-cli exited ${code}: ${lastStderrLine}${hint}`
: `paperclipai agent local-cli exited ${code}${hint}`;
reject(new Error(msg));
return;
}
if (!cleanedStdout) {
const hint = "run `paperclipai onboard` to authenticate the CLI";
reject(
new Error(`paperclipai agent local-cli produced no output — ${hint}`),
);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(cleanedStdout);
} catch {
reject(
new Error(
`paperclipai agent local-cli returned non-JSON output: ${cleanedStdout.slice(0, 200)}`,
),
);
return;
}
const r = parsed as Record<string, unknown>;
// Be defensive about field casing: try apiKey, api_key, token
const apiKey =
(typeof r.apiKey === "string" ? r.apiKey : undefined) ??
(typeof r.api_key === "string" ? r.api_key : undefined) ??
(typeof r.token === "string" ? r.token : undefined);
if (!apiKey) {
reject(
new Error(
`paperclipai agent local-cli JSON missing apiKey/api_key/token field: ${cleanedStdout.slice(0, 200)}`,
),
);
return;
}
const apiBase =
(typeof r.apiBase === "string" ? r.apiBase : undefined) ??
(typeof r.api_base === "string" ? r.api_base : undefined);
const agentId =
(typeof r.agentId === "string" ? r.agentId : undefined) ??
(typeof r.id === "string" ? r.id : undefined);
const companyId =
typeof r.companyId === "string" ? r.companyId : undefined;
resolve({ apiKey, apiBase, agentId, companyId, raw: parsed });
});
});
}
// ---------------------------------------------------------------------------
// Legacy probe (used by index.ts onLoad)
// ---------------------------------------------------------------------------
export interface ProbeResult {
ok: true;
deploymentMode: string | undefined;
}
export interface ProbeFailure {
ok: false;
error: string;
}
export type ProbePaperclipResult = ProbeResult | ProbeFailure;
/**
* Quick health-check for the Paperclip server via `GET /api/health`.
* Used by the plugin's `onLoad` hook.
*/
export async function probePaperclipInstance(
apiUrl: string,
apiKey?: string,
): Promise<ProbePaperclipResult> {
try {
const result = await request<{ status?: string; deploymentMode?: string }>(
apiUrl,
"/health",
{ apiKey },
);
if (result.status !== "ok") {
return {
ok: false,
error: `Paperclip health check did not return ok status${result.status ? ` (status=${result.status})` : ""}`,
};
}
return { ok: true, deploymentMode: result.deploymentMode };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@@ -1,374 +0,0 @@
/**
* Paperclip REST API client.
*
* This module intentionally does not depend on @fusion/engine.
*/
export interface PaperclipConfig {
apiUrl: string;
apiKey: string | undefined;
agentId: string | undefined;
companyId: string | undefined;
}
export interface ProbeResult {
ok: true;
deploymentMode: string | undefined;
}
export interface ProbeFailure {
ok: false;
error: string;
}
export type ProbePaperclipResult = ProbeResult | ProbeFailure;
export interface AgentIdentity {
id: string;
name?: string;
companyId: string;
role?: string;
status?: string;
}
export type AgentIdentityResult =
| { ok: true; agent: AgentIdentity }
| { ok: false; reason: "unauthenticated" | "not_agent" };
export interface ListIssuesFilters {
status?: string | string[];
assigneeAgentId?: string;
projectId?: string;
}
export class ConflictError extends Error {
readonly status = 409;
constructor(message: string) {
super(message);
this.name = "ConflictError";
}
}
interface ParsedBody {
value: unknown;
raw: string;
}
function normalizeApiUrl(url: string): string {
return url.replace(/\/+$/, "");
}
function getSettingString(settings: Record<string, unknown> | undefined, key: string): string | undefined {
const value = settings?.[key];
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
}
function buildApiUrl(apiUrl: string, path: string): string {
const base = normalizeApiUrl(apiUrl);
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${base}/api${normalizedPath}`;
}
function toErrorMessage(status: number, statusText: string, body: unknown, rawBody: string): string {
if (body && typeof body === "object") {
if (typeof (body as { error?: unknown }).error === "string") {
return (body as { error: string }).error;
}
if (typeof (body as { message?: unknown }).message === "string") {
return (body as { message: string }).message;
}
}
if (rawBody.trim() !== "") {
return rawBody.trim();
}
return `${status} ${statusText}`.trim();
}
async function parseBody(response: Response): Promise<ParsedBody> {
const raw = await response.text();
if (raw.trim() === "") {
return { value: undefined, raw };
}
try {
return { value: JSON.parse(raw), raw };
} catch {
throw new Error(
`Paperclip API ${response.status} ${response.statusText}: invalid JSON response body`,
);
}
}
async function request<T>(
apiUrl: string,
path: string,
options?: {
method?: string;
apiKey?: string;
body?: unknown;
runId?: string;
query?: URLSearchParams;
},
): Promise<T> {
const method = options?.method ?? "GET";
const url = `${buildApiUrl(apiUrl, path)}${options?.query ? `?${options.query.toString()}` : ""}`;
const headers: Record<string, string> = {
Accept: "application/json",
};
if (options?.apiKey) {
headers.Authorization = `Bearer ${options.apiKey}`;
}
if (options?.runId) {
headers["X-Paperclip-Run-Id"] = options.runId;
}
let body: string | undefined;
if (options && "body" in options && options.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
let response: Response;
try {
response = await fetch(url, { method, headers, body });
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Paperclip API network error (${method} ${url}): ${reason}`);
}
const parsed = await parseBody(response);
if (!response.ok) {
const message = toErrorMessage(response.status, response.statusText, parsed.value, parsed.raw);
const errorMessage = `Paperclip API ${response.status} (${method} ${path}): ${message}`;
if (response.status === 409) {
throw new ConflictError(errorMessage);
}
throw new Error(errorMessage);
}
return parsed.value as T;
}
export function resolvePaperclipConfig(settings?: Record<string, unknown>): PaperclipConfig {
const apiUrl =
getSettingString(settings, "apiUrl") ??
process.env.PAPERCLIP_API_URL?.trim() ??
"http://localhost:3100";
const envApiKey = process.env.PAPERCLIP_API_KEY?.trim() || undefined;
const envAgentId = process.env.PAPERCLIP_AGENT_ID?.trim() || undefined;
const envCompanyId = process.env.PAPERCLIP_COMPANY_ID?.trim() || undefined;
return {
apiUrl: normalizeApiUrl(apiUrl),
apiKey: getSettingString(settings, "apiKey") ?? envApiKey,
agentId: getSettingString(settings, "agentId") ?? envAgentId,
companyId: getSettingString(settings, "companyId") ?? envCompanyId,
};
}
export async function probePaperclipInstance(
apiUrl: string,
apiKey?: string,
): Promise<ProbePaperclipResult> {
try {
const result = await request<{ status?: string; deploymentMode?: string }>(apiUrl, "/health", {
apiKey,
});
if (result.status !== "ok") {
return {
ok: false,
error: `Paperclip health check did not return ok status${
result.status ? ` (status=${result.status})` : ""
}`,
};
}
return { ok: true, deploymentMode: result.deploymentMode };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
export async function getAgentIdentity(apiUrl: string, apiKey?: string): Promise<AgentIdentityResult> {
const url = buildApiUrl(apiUrl, "/agents/me");
const headers: Record<string, string> = { Accept: "application/json" };
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
let response: Response;
try {
response = await fetch(url, { method: "GET", headers });
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Paperclip API network error (GET ${url}): ${reason}`);
}
if (response.status === 401) {
return { ok: false, reason: "unauthenticated" };
}
if (response.status === 403) {
return { ok: false, reason: "not_agent" };
}
const parsed = await parseBody(response);
if (!response.ok) {
const message = toErrorMessage(response.status, response.statusText, parsed.value, parsed.raw);
throw new Error(`Paperclip API ${response.status} (GET /agents/me): ${message}`);
}
const agent = parsed.value as Partial<AgentIdentity>;
if (!agent.id || !agent.companyId) {
throw new Error("Paperclip API returned invalid agent identity response");
}
return {
ok: true,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
role: agent.role,
status: agent.status,
},
};
}
export async function listIssues(
apiUrl: string,
apiKey: string | undefined,
companyId: string,
filters?: ListIssuesFilters,
): Promise<unknown[]> {
const query = new URLSearchParams();
if (filters?.status) {
query.set("status", Array.isArray(filters.status) ? filters.status.join(",") : filters.status);
}
if (filters?.assigneeAgentId) {
query.set("assigneeAgentId", filters.assigneeAgentId);
}
if (filters?.projectId) {
query.set("projectId", filters.projectId);
}
return request<unknown[]>(apiUrl, `/companies/${companyId}/issues`, {
apiKey,
query: query.size > 0 ? query : undefined,
});
}
export async function getIssue(
apiUrl: string,
apiKey: string | undefined,
issueId: string,
): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}`, { apiKey });
}
export async function createIssue(
apiUrl: string,
apiKey: string | undefined,
companyId: string,
issue: {
title: string;
description: string;
status: string;
assigneeAgentId: string;
},
): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>(apiUrl, `/companies/${companyId}/issues`, {
method: "POST",
apiKey,
body: issue,
});
}
export async function updateIssue(
apiUrl: string,
apiKey: string | undefined,
issueId: string,
patch: Record<string, unknown>,
runId?: string,
): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}`, {
method: "PATCH",
apiKey,
body: patch,
runId,
});
}
export async function checkoutIssue(
apiUrl: string,
apiKey: string | undefined,
issueId: string,
agentId: string,
runId?: string,
): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}/checkout`, {
method: "POST",
apiKey,
body: {
agentId,
expectedStatuses: ["todo", "backlog"],
},
runId,
});
}
export async function getIssueComments(
apiUrl: string,
apiKey: string | undefined,
issueId: string,
): Promise<Array<Record<string, unknown>>> {
return request<Array<Record<string, unknown>>>(apiUrl, `/issues/${issueId}/comments`, { apiKey });
}
export async function addComment(
apiUrl: string,
apiKey: string | undefined,
issueId: string,
body: string,
runId?: string,
): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>(apiUrl, `/issues/${issueId}/comments`, {
method: "POST",
apiKey,
body: { body },
runId,
});
}
export async function invokeHeartbeat(
apiUrl: string,
apiKey: string | undefined,
agentId: string,
): Promise<{ ok: true; skipped: true } | { ok: true; run: Record<string, unknown> }> {
const result = await request<Record<string, unknown>>(apiUrl, `/agents/${agentId}/heartbeat/invoke`, {
method: "POST",
apiKey,
});
if (result.status === "skipped") {
return { ok: true, skipped: true };
}
return { ok: true, run: result };
}

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;
}

View File

@@ -21,14 +21,43 @@ export interface AgentRuntimeOptions {
skills?: string[];
}
export type PaperclipMode = "issue-per-prompt" | "rolling-issue" | "wakeup-only";
/**
* How the adapter authenticates to Paperclip.
*
* - `"api"` (default): caller provides apiUrl + apiKey explicitly. Suitable
* for cloud installs or when you want to bypass the local CLI.
* - `"cli"`: derive the apiUrl (and, if available, apiKey) from a local
* `paperclipai` install. Reads `~/.paperclip/instances/default/config.json`
* to get host:port; for local-trusted deployments no key is required.
*/
export type PaperclipTransport = "api" | "cli";
export interface PaperclipSession {
apiUrl: string;
apiKey: string | undefined;
/** Resolved at session-create time (auto-derived from /agents/me if not configured). */
agentId: string;
/** Resolved at session-create time (auto-derived from /agents/me if not configured). */
companyId: string;
/** Logical Fusion session id; used as the basis for idempotency keys. */
sessionId: string;
systemPrompt: string;
cwd: string;
mode: PaperclipMode;
/** Optional issue scoping passed at create time. */
parentIssueId?: string;
projectId?: string;
goalId?: string;
/** Set by the adapter on first prompt in `rolling-issue` mode; reused thereafter. */
issueId?: string;
/** Incremented per prompt. Combined with sessionId to form an idempotency key. */
turnIndex: number;
/** Hard cap for a single wakeup-run polling loop. */
runTimeoutMs: number;
pollIntervalMs: number;
pollIntervalMaxMs: number;
onText: ((text: string) => void) | undefined;
onThinking: ((text: string) => void) | undefined;
onToolStart: ((toolName: string, args?: unknown) => void) | undefined;
@@ -55,6 +84,26 @@ export interface PaperclipRuntimeConfig {
apiKey?: string;
agentId?: string;
companyId?: string;
mode?: PaperclipMode;
/**
* Auth/discovery transport. Default `"api"`.
* When `"cli"`, the adapter spawns `paperclipai` (or reads its config) to
* derive apiUrl/apiKey at session-create time.
*/
transport?: PaperclipTransport;
/** Path to the `paperclipai` binary when transport=cli. Default `"paperclipai"`. */
cliBinaryPath?: string;
/**
* Path to the paperclipai instance config file. Default
* `~/.paperclip/instances/default/config.json`.
*/
cliConfigPath?: string;
parentIssueId?: string;
projectId?: string;
goalId?: string;
runTimeoutMs?: number;
pollIntervalMs?: number;
pollIntervalMaxMs?: number;
}
export interface RuntimeLogger {