Merge public-gh/master into paperclip-company-import-export

This commit is contained in:
Dotta
2026-03-17 10:19:31 -05:00
33 changed files with 987 additions and 81 deletions

1
.gitignore vendored
View File

@@ -47,3 +47,4 @@ tmp/
tests/e2e/test-results/ tests/e2e/test-results/
tests/e2e/playwright-report/ tests/e2e/playwright-report/
.superset/ .superset/
.claude/worktrees/

View File

@@ -7,6 +7,7 @@ We really appreciate both small fixes and thoughtful larger changes.
## Two Paths to Get Your Pull Request Accepted ## Two Paths to Get Your Pull Request Accepted
### Path 1: Small, Focused Changes (Fastest way to get merged) ### Path 1: Small, Focused Changes (Fastest way to get merged)
- Pick **one** clear thing to fix/improve - Pick **one** clear thing to fix/improve
- Touch the **smallest possible number of files** - Touch the **smallest possible number of files**
- Make sure the change is very targeted and easy to review - Make sure the change is very targeted and easy to review
@@ -16,6 +17,7 @@ We really appreciate both small fixes and thoughtful larger changes.
These almost always get merged quickly when they're clean. These almost always get merged quickly when they're clean.
### Path 2: Bigger or Impactful Changes ### Path 2: Bigger or Impactful Changes
- **First** talk about it in Discord → #dev channel - **First** talk about it in Discord → #dev channel
→ Describe what you're trying to solve → Describe what you're trying to solve
→ Share rough ideas / approach → Share rough ideas / approach
@@ -30,12 +32,43 @@ These almost always get merged quickly when they're clean.
PRs that follow this path are **much** more likely to be accepted, even when they're large. PRs that follow this path are **much** more likely to be accepted, even when they're large.
## General Rules (both paths) ## General Rules (both paths)
- Write clear commit messages - Write clear commit messages
- Keep PR title + description meaningful - Keep PR title + description meaningful
- One PR = one logical change (unless it's a small related group) - One PR = one logical change (unless it's a small related group)
- Run tests locally first - Run tests locally first
- Be kind in discussions 😄 - Be kind in discussions 😄
## Writing a Good PR message
Please include a "thinking path" at the top of your PR message that explains from the top of the project down to what you fixed. E.g.:
### Thinking Path Example 1:
> - Paperclip orchestrates ai-agents for zero-human companies
> - There are many types of adapters for each LLM model provider
> - But LLM's have a context limit and not all agents can automatically compact their context
> - So we need to have an adapter-specific configuration for which adapters can and cannot automatically compact their context
> - This pull request adds per-adapter configuration of compaction, either auto or paperclip managed
> - That way we can get optimal performance from any adapter/provider in Paperclip
### Thinking Path Example 2:
> - Paperclip orchestrates ai-agents for zero-human companies
> - But humans want to watch the agents and oversee their work
> - Human users also operate in teams and so they need their own logins, profiles, views etc.
> - So we have a multi-user system for humans
> - But humans want to be able to update their own profile picture and avatar
> - But the avatar upload form wasn't saving the avatar to the file storage system
> - So this PR fixes the avatar upload form to use the file storage service
> - The benefit is we don't have a one-off file storage for just one aspect of the system, which would cause confusion and extra configuration
Then have the rest of your normal PR message after the Thinking Path.
This should include details about what you did, why you did it, why it matters & the benefits, how we can verify it works, and any risks.
Please include screenshots if possible if you have a visible change. (use something like the [agent-browser skill](https://github.com/vercel-labs/agent-browser/blob/main/skills/agent-browser/SKILL.md) or similar to take screenshots). Ideally, you include before and after screenshots.
Questions? Just ask in #dev — we're happy to help. Questions? Just ask in #dev — we're happy to help.
Happy hacking! Happy hacking!

View File

@@ -29,6 +29,20 @@ export type {
CLIAdapterModule, CLIAdapterModule,
CreateConfigValues, CreateConfigValues,
} from "./types.js"; } from "./types.js";
export type {
SessionCompactionPolicy,
NativeContextManagement,
AdapterSessionManagement,
ResolvedSessionCompactionPolicy,
} from "./session-compaction.js";
export {
ADAPTER_SESSION_MANAGEMENT,
LEGACY_SESSIONED_ADAPTER_TYPES,
getAdapterSessionManagement,
readSessionCompactionOverride,
resolveSessionCompactionPolicy,
hasSessionCompactionThresholds,
} from "./session-compaction.js";
export { export {
REDACTED_HOME_PATH_USER, REDACTED_HOME_PATH_USER,
redactHomePathUserSegments, redactHomePathUserSegments,

View File

@@ -0,0 +1,175 @@
export interface SessionCompactionPolicy {
enabled: boolean;
maxSessionRuns: number;
maxRawInputTokens: number;
maxSessionAgeHours: number;
}
export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none";
export interface AdapterSessionManagement {
supportsSessionResume: boolean;
nativeContextManagement: NativeContextManagement;
defaultSessionCompaction: SessionCompactionPolicy;
}
export interface ResolvedSessionCompactionPolicy {
policy: SessionCompactionPolicy;
adapterSessionManagement: AdapterSessionManagement | null;
explicitOverride: Partial<SessionCompactionPolicy>;
source: "adapter_default" | "agent_override" | "legacy_fallback";
}
const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = {
enabled: true,
maxSessionRuns: 200,
maxRawInputTokens: 2_000_000,
maxSessionAgeHours: 72,
};
// Adapters with native context management still participate in session resume,
// but Paperclip should not rotate them using threshold-based compaction.
const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = {
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
};
export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
"claude_local",
"codex_local",
"cursor",
"gemini_local",
"opencode_local",
"pi_local",
]);
export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement> = {
claude_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
},
codex_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
},
cursor: {
supportsSessionResume: true,
nativeContextManagement: "unknown",
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
},
gemini_local: {
supportsSessionResume: true,
nativeContextManagement: "unknown",
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
},
opencode_local: {
supportsSessionResume: true,
nativeContextManagement: "unknown",
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
},
pi_local: {
supportsSessionResume: true,
nativeContextManagement: "unknown",
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
},
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (typeof value === "number") {
if (value === 1) return true;
if (value === 0) return false;
return undefined;
}
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
return false;
}
return undefined;
}
function readNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(0, Math.floor(value));
}
if (typeof value !== "string") return undefined;
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : undefined;
}
export function getAdapterSessionManagement(adapterType: string | null | undefined): AdapterSessionManagement | null {
if (!adapterType) return null;
return ADAPTER_SESSION_MANAGEMENT[adapterType] ?? null;
}
export function readSessionCompactionOverride(runtimeConfig: unknown): Partial<SessionCompactionPolicy> {
const runtime = isRecord(runtimeConfig) ? runtimeConfig : {};
const heartbeat = isRecord(runtime.heartbeat) ? runtime.heartbeat : {};
const compaction = isRecord(
heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction,
)
? (heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction) as Record<string, unknown>
: {};
const explicit: Partial<SessionCompactionPolicy> = {};
const enabled = readBoolean(compaction.enabled);
const maxSessionRuns = readNumber(compaction.maxSessionRuns);
const maxRawInputTokens = readNumber(compaction.maxRawInputTokens);
const maxSessionAgeHours = readNumber(compaction.maxSessionAgeHours);
if (enabled !== undefined) explicit.enabled = enabled;
if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns;
if (maxRawInputTokens !== undefined) explicit.maxRawInputTokens = maxRawInputTokens;
if (maxSessionAgeHours !== undefined) explicit.maxSessionAgeHours = maxSessionAgeHours;
return explicit;
}
export function resolveSessionCompactionPolicy(
adapterType: string | null | undefined,
runtimeConfig: unknown,
): ResolvedSessionCompactionPolicy {
const adapterSessionManagement = getAdapterSessionManagement(adapterType);
const explicitOverride = readSessionCompactionOverride(runtimeConfig);
const hasExplicitOverride = Object.keys(explicitOverride).length > 0;
const fallbackEnabled = Boolean(adapterType && LEGACY_SESSIONED_ADAPTER_TYPES.has(adapterType));
const basePolicy = adapterSessionManagement?.defaultSessionCompaction ?? {
...DEFAULT_SESSION_COMPACTION_POLICY,
enabled: fallbackEnabled,
};
return {
policy: {
enabled: explicitOverride.enabled ?? basePolicy.enabled,
maxSessionRuns: explicitOverride.maxSessionRuns ?? basePolicy.maxSessionRuns,
maxRawInputTokens: explicitOverride.maxRawInputTokens ?? basePolicy.maxRawInputTokens,
maxSessionAgeHours: explicitOverride.maxSessionAgeHours ?? basePolicy.maxSessionAgeHours,
},
adapterSessionManagement,
explicitOverride,
source: hasExplicitOverride
? "agent_override"
: adapterSessionManagement
? "adapter_default"
: "legacy_fallback",
};
}
export function hasSessionCompactionThresholds(policy: Pick<
SessionCompactionPolicy,
"maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours"
>) {
return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0;
}

View File

@@ -257,6 +257,7 @@ export interface ServerAdapterModule {
listSkills?: (ctx: AdapterSkillContext) => Promise<AdapterSkillSnapshot>; listSkills?: (ctx: AdapterSkillContext) => Promise<AdapterSkillSnapshot>;
syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise<AdapterSkillSnapshot>; syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise<AdapterSkillSnapshot>;
sessionCodec?: AdapterSessionCodec; sessionCodec?: AdapterSessionCodec;
sessionManagement?: import("./session-compaction.js").AdapterSessionManagement;
supportsLocalAgentJwt?: boolean; supportsLocalAgentJwt?: boolean;
models?: AdapterModel[]; models?: AdapterModel[];
listModels?: () => Promise<AdapterModel[]>; listModels?: () => Promise<AdapterModel[]>;

View File

@@ -165,6 +165,14 @@ export interface HostServices {
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>; createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
}; };
/** Provides `issues.documents.list`, `issues.documents.get`, `issues.documents.upsert`, `issues.documents.delete`. */
issueDocuments: {
list(params: WorkerToHostMethods["issues.documents.list"][0]): Promise<WorkerToHostMethods["issues.documents.list"][1]>;
get(params: WorkerToHostMethods["issues.documents.get"][0]): Promise<WorkerToHostMethods["issues.documents.get"][1]>;
upsert(params: WorkerToHostMethods["issues.documents.upsert"][0]): Promise<WorkerToHostMethods["issues.documents.upsert"][1]>;
delete(params: WorkerToHostMethods["issues.documents.delete"][0]): Promise<WorkerToHostMethods["issues.documents.delete"][1]>;
};
/** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */ /** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */
agents: { agents: {
list(params: WorkerToHostMethods["agents.list"][0]): Promise<WorkerToHostMethods["agents.list"][1]>; list(params: WorkerToHostMethods["agents.list"][0]): Promise<WorkerToHostMethods["agents.list"][1]>;
@@ -298,6 +306,12 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"issues.listComments": "issue.comments.read", "issues.listComments": "issue.comments.read",
"issues.createComment": "issue.comments.create", "issues.createComment": "issue.comments.create",
// Issue Documents
"issues.documents.list": "issue.documents.read",
"issues.documents.get": "issue.documents.read",
"issues.documents.upsert": "issue.documents.write",
"issues.documents.delete": "issue.documents.write",
// Agents // Agents
"agents.list": "agents.read", "agents.list": "agents.read",
"agents.get": "agents.read", "agents.get": "agents.read",
@@ -483,6 +497,20 @@ export function createHostClientHandlers(
return services.issues.createComment(params); return services.issues.createComment(params);
}), }),
// Issue Documents
"issues.documents.list": gated("issues.documents.list", async (params) => {
return services.issueDocuments.list(params);
}),
"issues.documents.get": gated("issues.documents.get", async (params) => {
return services.issueDocuments.get(params);
}),
"issues.documents.upsert": gated("issues.documents.upsert", async (params) => {
return services.issueDocuments.upsert(params);
}),
"issues.documents.delete": gated("issues.documents.delete", async (params) => {
return services.issueDocuments.delete(params);
}),
// Agents // Agents
"agents.list": gated("agents.list", async (params) => { "agents.list": gated("agents.list", async (params) => {
return services.agents.list(params); return services.agents.list(params);

View File

@@ -25,6 +25,8 @@ import type {
Project, Project,
Issue, Issue,
IssueComment, IssueComment,
IssueDocument,
IssueDocumentSummary,
Agent, Agent,
Goal, Goal,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
@@ -601,6 +603,32 @@ export interface WorkerToHostMethods {
result: IssueComment, result: IssueComment,
]; ];
// Issue Documents
"issues.documents.list": [
params: { issueId: string; companyId: string },
result: IssueDocumentSummary[],
];
"issues.documents.get": [
params: { issueId: string; key: string; companyId: string },
result: IssueDocument | null,
];
"issues.documents.upsert": [
params: {
issueId: string;
key: string;
body: string;
companyId: string;
title?: string;
format?: string;
changeSummary?: string;
},
result: IssueDocument,
];
"issues.documents.delete": [
params: { issueId: string; key: string; companyId: string },
result: void,
];
// Agents (read) // Agents (read)
"agents.list": [ "agents.list": [
params: { companyId: string; status?: string; limit?: number; offset?: number }, params: { companyId: string; status?: string; limit?: number; offset?: number },

View File

@@ -422,6 +422,33 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
issueComments.set(issueId, current); issueComments.set(issueId, current);
return comment; return comment;
}, },
documents: {
async list(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read");
if (!isInCompany(issues.get(issueId), companyId)) return [];
return [];
},
async get(issueId, _key, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read");
if (!isInCompany(issues.get(issueId), companyId)) return null;
return null;
},
async upsert(input) {
requireCapability(manifest, capabilitySet, "issue.documents.write");
const parentIssue = issues.get(input.issueId);
if (!isInCompany(parentIssue, input.companyId)) {
throw new Error(`Issue not found: ${input.issueId}`);
}
throw new Error("documents.upsert is not implemented in test context");
},
async delete(issueId, _key, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.write");
const parentIssue = issues.get(issueId);
if (!isInCompany(parentIssue, companyId)) {
throw new Error(`Issue not found: ${issueId}`);
}
},
},
}, },
agents: { agents: {
async list(input) { async list(input) {

View File

@@ -19,6 +19,8 @@ import type {
Project, Project,
Issue, Issue,
IssueComment, IssueComment,
IssueDocument,
IssueDocumentSummary,
Agent, Agent,
Goal, Goal,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
@@ -61,6 +63,8 @@ export type {
Project, Project,
Issue, Issue,
IssueComment, IssueComment,
IssueDocument,
IssueDocumentSummary,
Agent, Agent,
Goal, Goal,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
@@ -774,6 +778,73 @@ export interface PluginCompaniesClient {
get(companyId: string): Promise<Company | null>; get(companyId: string): Promise<Company | null>;
} }
/**
* `ctx.issues.documents` — read and write issue documents.
*
* Requires:
* - `issue.documents.read` for `list` and `get`
* - `issue.documents.write` for `upsert` and `delete`
*
* @see PLUGIN_SPEC.md §14 — SDK Surface
*/
export interface PluginIssueDocumentsClient {
/**
* List all documents attached to an issue.
*
* Returns summary metadata (id, key, title, format, timestamps) without
* the full document body. Use `get()` to fetch a specific document's body.
*
* Requires the `issue.documents.read` capability.
*/
list(issueId: string, companyId: string): Promise<IssueDocumentSummary[]>;
/**
* Get a single document by key, including its full body content.
*
* Returns `null` if no document exists with the given key.
*
* Requires the `issue.documents.read` capability.
*
* @param issueId - UUID of the issue
* @param key - Document key (e.g. `"plan"`, `"design-spec"`)
* @param companyId - UUID of the company
*/
get(issueId: string, key: string, companyId: string): Promise<IssueDocument | null>;
/**
* Create or update a document on an issue.
*
* If a document with the given key already exists, it is updated and a new
* revision is created. If it does not exist, it is created.
*
* Requires the `issue.documents.write` capability.
*
* @param input - Document data including issueId, key, body, and optional title/format/changeSummary
*/
upsert(input: {
issueId: string;
key: string;
body: string;
companyId: string;
title?: string;
format?: string;
changeSummary?: string;
}): Promise<IssueDocument>;
/**
* Delete a document and all its revisions.
*
* No-ops silently if the document does not exist (idempotent).
*
* Requires the `issue.documents.write` capability.
*
* @param issueId - UUID of the issue
* @param key - Document key to delete
* @param companyId - UUID of the company
*/
delete(issueId: string, key: string, companyId: string): Promise<void>;
}
/** /**
* `ctx.issues` — read and mutate issues plus comments. * `ctx.issues` — read and mutate issues plus comments.
* *
@@ -783,6 +854,8 @@ export interface PluginCompaniesClient {
* - `issues.update` for update * - `issues.update` for update
* - `issue.comments.read` for `listComments` * - `issue.comments.read` for `listComments`
* - `issue.comments.create` for `createComment` * - `issue.comments.create` for `createComment`
* - `issue.documents.read` for `documents.list` and `documents.get`
* - `issue.documents.write` for `documents.upsert` and `documents.delete`
*/ */
export interface PluginIssuesClient { export interface PluginIssuesClient {
list(input: { list(input: {
@@ -814,6 +887,8 @@ export interface PluginIssuesClient {
): Promise<Issue>; ): Promise<Issue>;
listComments(issueId: string, companyId: string): Promise<IssueComment[]>; listComments(issueId: string, companyId: string): Promise<IssueComment[]>;
createComment(issueId: string, body: string, companyId: string): Promise<IssueComment>; createComment(issueId: string, body: string, companyId: string): Promise<IssueComment>;
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
documents: PluginIssueDocumentsClient;
} }
/** /**
@@ -1056,7 +1131,7 @@ export interface PluginContext {
/** Read company metadata. Requires `companies.read`. */ /** Read company metadata. Requires `companies.read`. */
companies: PluginCompaniesClient; companies: PluginCompaniesClient;
/** Read and write issues/comments. Requires issue capabilities. */ /** Read and write issues, comments, and documents. Requires issue capabilities. */
issues: PluginIssuesClient; issues: PluginIssuesClient;
/** Read and manage agents. Requires `agents.read` for reads; `agents.pause` / `agents.resume` / `agents.invoke` for write ops. */ /** Read and manage agents. Requires `agents.read` for reads; `agents.pause` / `agents.resume` / `agents.invoke` for write ops. */

View File

@@ -612,6 +612,32 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
async createComment(issueId: string, body: string, companyId: string) { async createComment(issueId: string, body: string, companyId: string) {
return callHost("issues.createComment", { issueId, body, companyId }); return callHost("issues.createComment", { issueId, body, companyId });
}, },
documents: {
async list(issueId: string, companyId: string) {
return callHost("issues.documents.list", { issueId, companyId });
},
async get(issueId: string, key: string, companyId: string) {
return callHost("issues.documents.get", { issueId, key, companyId });
},
async upsert(input) {
return callHost("issues.documents.upsert", {
issueId: input.issueId,
key: input.key,
body: input.body,
companyId: input.companyId,
title: input.title,
format: input.format,
changeSummary: input.changeSummary,
});
},
async delete(issueId: string, key: string, companyId: string) {
return callHost("issues.documents.delete", { issueId, key, companyId });
},
},
}, },
agents: { agents: {

View File

@@ -385,6 +385,7 @@ export const PLUGIN_CAPABILITIES = [
"project.workspaces.read", "project.workspaces.read",
"issues.read", "issues.read",
"issue.comments.read", "issue.comments.read",
"issue.documents.read",
"agents.read", "agents.read",
"goals.read", "goals.read",
"goals.create", "goals.create",
@@ -395,6 +396,7 @@ export const PLUGIN_CAPABILITIES = [
"issues.create", "issues.create",
"issues.update", "issues.update",
"issue.comments.create", "issue.comments.create",
"issue.documents.write",
"agents.pause", "agents.pause",
"agents.resume", "agents.resume",
"agents.invoke", "agents.invoke",

71
scripts/kill-dev.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
#
# Kill all local Paperclip dev server processes (across all worktrees).
#
# Usage:
# scripts/kill-dev.sh # kill all paperclip dev processes
# scripts/kill-dev.sh --dry # preview what would be killed
#
set -euo pipefail
DRY_RUN=false
if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then
DRY_RUN=true
fi
# Collect PIDs of node processes running from any paperclip directory.
# Matches paths like /Users/*/paperclip/... or /Users/*/paperclip-*/...
# Excludes postgres-related processes.
pids=()
lines=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
# skip postgres processes
[[ "$line" == *postgres* ]] && continue
pid=$(echo "$line" | awk '{print $2}')
pids+=("$pid")
lines+=("$line")
done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true)
if [[ ${#pids[@]} -eq 0 ]]; then
echo "No Paperclip dev processes found."
exit 0
fi
echo "Found ${#pids[@]} Paperclip dev process(es):"
echo ""
for i in "${!pids[@]}"; do
line="${lines[$i]}"
pid=$(echo "$line" | awk '{print $2}')
start=$(echo "$line" | awk '{print $9}')
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
# Shorten the command for readability
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
done
echo ""
if [[ "$DRY_RUN" == true ]]; then
echo "Dry run — re-run without --dry to kill these processes."
exit 0
fi
echo "Sending SIGTERM..."
for pid in "${pids[@]}"; do
kill "$pid" 2>/dev/null && echo " killed $pid" || echo " $pid already gone"
done
# Give processes a moment to exit, then SIGKILL any stragglers
sleep 2
for pid in "${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
echo " $pid still alive, sending SIGKILL..."
kill -9 "$pid" 2>/dev/null || true
fi
done
echo "Done."

View File

@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import express from "express"; import express from "express";
import request from "supertest"; import request from "supertest";
import { healthRoutes } from "../routes/health.js"; import { healthRoutes } from "../routes/health.js";
import { serverVersion } from "../version.js";
describe("GET /health", () => { describe("GET /health", () => {
const app = express(); const app = express();
@@ -10,6 +11,6 @@ describe("GET /health", () => {
it("returns 200 with status ok", async () => { it("returns 200 with status ok", async () => {
const res = await request(app).get("/health"); const res = await request(app).get("/health");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "ok" }); expect(res.body).toEqual({ status: "ok", version: serverVersion });
}); });
}); });

View File

@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { agents } from "@paperclipai/db";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import { import {
parseSessionCompactionPolicy,
resolveRuntimeSessionParamsForWorkspace, resolveRuntimeSessionParamsForWorkspace,
shouldResetTaskSessionForWake, shouldResetTaskSessionForWake,
type ResolvedWorkspaceForRun, type ResolvedWorkspaceForRun,
@@ -20,6 +22,32 @@ function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}
}; };
} }
function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown> = {}) {
return {
id: "agent-1",
companyId: "company-1",
projectId: null,
goalId: null,
name: "Agent",
role: "engineer",
title: null,
icon: null,
status: "running",
reportsTo: null,
capabilities: null,
adapterType,
adapterConfig: {},
runtimeConfig,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
permissions: {},
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as typeof agents.$inferSelect;
}
describe("resolveRuntimeSessionParamsForWorkspace", () => { describe("resolveRuntimeSessionParamsForWorkspace", () => {
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => { it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
const agentId = "agent-123"; const agentId = "agent-123";
@@ -151,3 +179,55 @@ describe("shouldResetTaskSessionForWake", () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe("parseSessionCompactionPolicy", () => {
it("disables Paperclip-managed rotation by default for codex and claude local", () => {
expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
});
expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
});
});
it("keeps conservative defaults for adapters without confirmed native compaction", () => {
expect(parseSessionCompactionPolicy(buildAgent("cursor"))).toEqual({
enabled: true,
maxSessionRuns: 200,
maxRawInputTokens: 2_000_000,
maxSessionAgeHours: 72,
});
expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({
enabled: true,
maxSessionRuns: 200,
maxRawInputTokens: 2_000_000,
maxSessionAgeHours: 72,
});
});
it("lets explicit agent overrides win over adapter defaults", () => {
expect(
parseSessionCompactionPolicy(
buildAgent("codex_local", {
heartbeat: {
sessionCompaction: {
maxSessionRuns: 25,
maxRawInputTokens: 500_000,
},
},
}),
),
).toEqual({
enabled: true,
maxSessionRuns: 25,
maxRawInputTokens: 500_000,
maxSessionAgeHours: 0,
});
});
});

View File

@@ -1,4 +1,5 @@
import type { ServerAdapterModule } from "./types.js"; import type { ServerAdapterModule } from "./types.js";
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
import { import {
execute as claudeExecute, execute as claudeExecute,
listClaudeSkills, listClaudeSkills,
@@ -84,6 +85,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
listSkills: listClaudeSkills, listSkills: listClaudeSkills,
syncSkills: syncClaudeSkills, syncSkills: syncClaudeSkills,
sessionCodec: claudeSessionCodec, sessionCodec: claudeSessionCodec,
sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined,
models: claudeModels, models: claudeModels,
supportsLocalAgentJwt: true, supportsLocalAgentJwt: true,
agentConfigurationDoc: claudeAgentConfigurationDoc, agentConfigurationDoc: claudeAgentConfigurationDoc,
@@ -97,6 +99,7 @@ const codexLocalAdapter: ServerAdapterModule = {
listSkills: listCodexSkills, listSkills: listCodexSkills,
syncSkills: syncCodexSkills, syncSkills: syncCodexSkills,
sessionCodec: codexSessionCodec, sessionCodec: codexSessionCodec,
sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined,
models: codexModels, models: codexModels,
listModels: listCodexModels, listModels: listCodexModels,
supportsLocalAgentJwt: true, supportsLocalAgentJwt: true,
@@ -111,6 +114,7 @@ const cursorLocalAdapter: ServerAdapterModule = {
listSkills: listCursorSkills, listSkills: listCursorSkills,
syncSkills: syncCursorSkills, syncSkills: syncCursorSkills,
sessionCodec: cursorSessionCodec, sessionCodec: cursorSessionCodec,
sessionManagement: getAdapterSessionManagement("cursor") ?? undefined,
models: cursorModels, models: cursorModels,
listModels: listCursorModels, listModels: listCursorModels,
supportsLocalAgentJwt: true, supportsLocalAgentJwt: true,
@@ -124,6 +128,7 @@ const geminiLocalAdapter: ServerAdapterModule = {
listSkills: listGeminiSkills, listSkills: listGeminiSkills,
syncSkills: syncGeminiSkills, syncSkills: syncGeminiSkills,
sessionCodec: geminiSessionCodec, sessionCodec: geminiSessionCodec,
sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined,
models: geminiModels, models: geminiModels,
supportsLocalAgentJwt: true, supportsLocalAgentJwt: true,
agentConfigurationDoc: geminiAgentConfigurationDoc, agentConfigurationDoc: geminiAgentConfigurationDoc,
@@ -145,6 +150,7 @@ const openCodeLocalAdapter: ServerAdapterModule = {
listSkills: listOpenCodeSkills, listSkills: listOpenCodeSkills,
syncSkills: syncOpenCodeSkills, syncSkills: syncOpenCodeSkills,
sessionCodec: openCodeSessionCodec, sessionCodec: openCodeSessionCodec,
sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined,
models: [], models: [],
listModels: listOpenCodeModels, listModels: listOpenCodeModels,
supportsLocalAgentJwt: true, supportsLocalAgentJwt: true,
@@ -158,6 +164,7 @@ const piLocalAdapter: ServerAdapterModule = {
listSkills: listPiSkills, listSkills: listPiSkills,
syncSkills: syncPiSkills, syncSkills: syncPiSkills,
sessionCodec: piSessionCodec, sessionCodec: piSessionCodec,
sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined,
models: [], models: [],
listModels: listPiModels, listModels: listPiModels,
supportsLocalAgentJwt: true, supportsLocalAgentJwt: true,

View File

@@ -3,6 +3,7 @@
// imports (process/, http/, heartbeat.ts) don't need rewriting. // imports (process/, http/, heartbeat.ts) don't need rewriting.
export type { export type {
AdapterAgent, AdapterAgent,
AdapterSessionManagement,
AdapterRuntime, AdapterRuntime,
UsageSummary, UsageSummary,
AdapterExecutionResult, AdapterExecutionResult,
@@ -20,5 +21,8 @@ export type {
AdapterSkillContext, AdapterSkillContext,
AdapterSessionCodec, AdapterSessionCodec,
AdapterModel, AdapterModel,
NativeContextManagement,
ResolvedSessionCompactionPolicy,
SessionCompactionPolicy,
ServerAdapterModule, ServerAdapterModule,
} from "@paperclipai/adapter-utils"; } from "@paperclipai/adapter-utils";

View File

@@ -100,6 +100,7 @@ function readSkillMarkdown(skillName: string): string | null {
if ( if (
normalized !== "paperclip" && normalized !== "paperclip" &&
normalized !== "paperclip-create-agent" && normalized !== "paperclip-create-agent" &&
normalized !== "paperclip-create-plugin" &&
normalized !== "para-memory-files" normalized !== "para-memory-files"
) )
return null; return null;
@@ -119,6 +120,90 @@ function readSkillMarkdown(skillName: string): string | null {
return null; return null;
} }
/** Resolve the Paperclip repo skills directory (built-in / managed skills). */
function resolvePaperclipSkillsDir(): string | null {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.resolve(moduleDir, "../../skills"), // published
path.resolve(process.cwd(), "skills"), // cwd (monorepo root)
path.resolve(moduleDir, "../../../skills"), // dev
];
for (const candidate of candidates) {
try {
if (fs.statSync(candidate).isDirectory()) return candidate;
} catch { /* skip */ }
}
return null;
}
/** Parse YAML frontmatter from a SKILL.md file to extract the description. */
function parseSkillFrontmatter(markdown: string): { description: string } {
const match = markdown.match(/^---\n([\s\S]*?)\n---/);
if (!match) return { description: "" };
const yaml = match[1];
// Extract description — handles both single-line and multi-line YAML values
const descMatch = yaml.match(
/^description:\s*(?:>\s*\n((?:\s{2,}[^\n]*\n?)+)|[|]\s*\n((?:\s{2,}[^\n]*\n?)+)|["']?(.*?)["']?\s*$)/m
);
if (!descMatch) return { description: "" };
const raw = descMatch[1] ?? descMatch[2] ?? descMatch[3] ?? "";
return {
description: raw
.split("\n")
.map((l: string) => l.trim())
.filter(Boolean)
.join(" ")
.trim(),
};
}
interface AvailableSkill {
name: string;
description: string;
isPaperclipManaged: boolean;
}
/** Discover all available Claude Code skills from ~/.claude/skills/. */
function listAvailableSkills(): AvailableSkill[] {
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
const claudeSkillsDir = path.join(homeDir, ".claude", "skills");
const paperclipSkillsDir = resolvePaperclipSkillsDir();
// Build set of Paperclip-managed skill names
const paperclipSkillNames = new Set<string>();
if (paperclipSkillsDir) {
try {
for (const entry of fs.readdirSync(paperclipSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory()) paperclipSkillNames.add(entry.name);
}
} catch { /* skip */ }
}
const skills: AvailableSkill[] = [];
try {
const entries = fs.readdirSync(claudeSkillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
if (entry.name.startsWith(".")) continue;
const skillMdPath = path.join(claudeSkillsDir, entry.name, "SKILL.md");
let description = "";
try {
const md = fs.readFileSync(skillMdPath, "utf8");
description = parseSkillFrontmatter(md).description;
} catch { /* no SKILL.md or unreadable */ }
skills.push({
name: entry.name,
description,
isPaperclipManaged: paperclipSkillNames.has(entry.name),
});
}
} catch { /* ~/.claude/skills/ doesn't exist */ }
skills.sort((a, b) => a.name.localeCompare(b.name));
return skills;
}
function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) { function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) {
const { claimSecretHash: _claimSecretHash, ...safe } = row; const { claimSecretHash: _claimSecretHash, ...safe } = row;
return safe; return safe;
@@ -1610,6 +1695,10 @@ export function accessRoutes(
return { token, created, normalizedAgentMessage }; return { token, created, normalizedAgentMessage };
} }
router.get("/skills/available", (_req, res) => {
res.json({ skills: listAvailableSkills() });
});
router.get("/skills/index", (_req, res) => { router.get("/skills/index", (_req, res) => {
res.json({ res.json({
skills: [ skills: [

View File

@@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
import { and, count, eq, gt, isNull, sql } from "drizzle-orm"; import { and, count, eq, gt, isNull, sql } from "drizzle-orm";
import { instanceUserRoles, invites } from "@paperclipai/db"; import { instanceUserRoles, invites } from "@paperclipai/db";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
import { serverVersion } from "../version.js";
export function healthRoutes( export function healthRoutes(
db?: Db, db?: Db,
@@ -22,7 +23,7 @@ export function healthRoutes(
router.get("/", async (_req, res) => { router.get("/", async (_req, res) => {
if (!db) { if (!db) {
res.json({ status: "ok" }); res.json({ status: "ok", version: serverVersion });
return; return;
} }
@@ -56,6 +57,7 @@ export function healthRoutes(
res.json({ res.json({
status: "ok", status: "ok",
version: serverVersion,
deploymentMode: opts.deploymentMode, deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure, deploymentExposure: opts.deploymentExposure,
authReady: opts.authReady, authReady: opts.authReady,

View File

@@ -116,7 +116,11 @@ export function projectRoutes(db: Db) {
return; return;
} }
assertCompanyAccess(req, existing.companyId); assertCompanyAccess(req, existing.companyId);
const project = await svc.update(id, req.body); const body = { ...req.body };
if (typeof body.archivedAt === "string") {
body.archivedAt = new Date(body.archivedAt);
}
const project = await svc.update(id, body);
if (!project) { if (!project) {
res.status(404).json({ error: "Project not found" }); res.status(404).json({ error: "Project not found" });
return; return;

View File

@@ -43,6 +43,11 @@ import {
resolveExecutionWorkspaceMode, resolveExecutionWorkspaceMode,
} from "./execution-workspace-policy.js"; } from "./execution-workspace-policy.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import {
hasSessionCompactionThresholds,
resolveSessionCompactionPolicy,
type SessionCompactionPolicy,
} from "@paperclipai/adapter-utils";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
@@ -50,14 +55,6 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const startLocksByAgent = new Map<string, Promise<void>>(); const startLocksByAgent = new Map<string, Promise<void>>();
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
"cursor",
"gemini_local",
"opencode_local",
"pi_local",
]);
const heartbeatRunListColumns = { const heartbeatRunListColumns = {
id: heartbeatRuns.id, id: heartbeatRuns.id,
@@ -134,13 +131,6 @@ type UsageTotals = {
outputTokens: number; outputTokens: number;
}; };
type SessionCompactionPolicy = {
enabled: boolean;
maxSessionRuns: number;
maxRawInputTokens: number;
maxSessionAgeHours: number;
};
type SessionCompactionDecision = { type SessionCompactionDecision = {
rotate: boolean; rotate: boolean;
reason: string | null; reason: string | null;
@@ -297,23 +287,8 @@ function formatCount(value: number | null | undefined) {
return value.toLocaleString("en-US"); return value.toLocaleString("en-US");
} }
function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy { export function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy {
const runtimeConfig = parseObject(agent.runtimeConfig); return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig).policy;
const heartbeat = parseObject(runtimeConfig.heartbeat);
const compaction = parseObject(
heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtimeConfig.sessionCompaction,
);
const supportsSessions = SESSIONED_LOCAL_ADAPTERS.has(agent.adapterType);
const enabled = compaction.enabled === undefined
? supportsSessions
: asBoolean(compaction.enabled, supportsSessions);
return {
enabled,
maxSessionRuns: Math.max(0, Math.floor(asNumber(compaction.maxSessionRuns, 200))),
maxRawInputTokens: Math.max(0, Math.floor(asNumber(compaction.maxRawInputTokens, 2_000_000))),
maxSessionAgeHours: Math.max(0, Math.floor(asNumber(compaction.maxSessionAgeHours, 72))),
};
} }
export function resolveRuntimeSessionParamsForWorkspace(input: { export function resolveRuntimeSessionParamsForWorkspace(input: {
@@ -745,7 +720,7 @@ export function heartbeatService(db: Db) {
} }
const policy = parseSessionCompactionPolicy(agent); const policy = parseSessionCompactionPolicy(agent);
if (!policy.enabled) { if (!policy.enabled || !hasSessionCompactionThresholds(policy)) {
return { return {
rotate: false, rotate: false,
reason: null, reason: null,

View File

@@ -16,6 +16,7 @@ import { agentService } from "./agents.js";
import { projectService } from "./projects.js"; import { projectService } from "./projects.js";
import { issueService } from "./issues.js"; import { issueService } from "./issues.js";
import { goalService } from "./goals.js"; import { goalService } from "./goals.js";
import { documentService } from "./documents.js";
import { heartbeatService } from "./heartbeat.js"; import { heartbeatService } from "./heartbeat.js";
import { subscribeCompanyLiveEvents } from "./live-events.js"; import { subscribeCompanyLiveEvents } from "./live-events.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -450,6 +451,7 @@ export function buildHostServices(
const heartbeat = heartbeatService(db); const heartbeat = heartbeatService(db);
const projects = projectService(db); const projects = projectService(db);
const issues = issueService(db); const issues = issueService(db);
const documents = documentService(db);
const goals = goalService(db); const goals = goalService(db);
const activity = activityService(db); const activity = activityService(db);
const costs = costService(db); const costs = costService(db);
@@ -796,6 +798,43 @@ export function buildHostServices(
}, },
}, },
issueDocuments: {
async list(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
const rows = await documents.listIssueDocuments(params.issueId);
return rows as any;
},
async get(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
const doc = await documents.getIssueDocumentByKey(params.issueId, params.key);
return (doc ?? null) as any;
},
async upsert(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
const result = await documents.upsertIssueDocument({
issueId: params.issueId,
key: params.key,
body: params.body,
title: params.title ?? null,
format: params.format ?? "markdown",
changeSummary: params.changeSummary ?? null,
});
return result.document as any;
},
async delete(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
await documents.deleteIssueDocument(params.issueId, params.key);
},
},
agents: { agents: {
async list(params) { async list(params) {
const companyId = ensureCompanyId(params.companyId); const companyId = ensureCompanyId(params.companyId);

10
server/src/version.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createRequire } from "node:module";
type PackageJson = {
version?: string;
};
const require = createRequire(import.meta.url);
const pkg = require("../package.json") as PackageJson;
export const serverVersion = pkg.version ?? "0.0.0";

View File

@@ -149,4 +149,12 @@ export const agentsApi = {
) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data), ) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data),
loginWithClaude: (id: string, companyId?: string) => loginWithClaude: (id: string, companyId?: string) =>
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}), api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
availableSkills: () =>
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
}; };
export interface AvailableSkill {
name: string;
description: string;
isPaperclipManaged: boolean;
}

View File

@@ -1,5 +1,6 @@
export type HealthStatus = { export type HealthStatus = {
status: "ok"; status: "ok";
version?: string;
deploymentMode?: "local_trusted" | "authenticated"; deploymentMode?: "local_trusted" | "authenticated";
deploymentExposure?: "private" | "public"; deploymentExposure?: "private" | "public";
authReady?: boolean; authReady?: boolean;

View File

@@ -1,6 +1,11 @@
import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import {
hasSessionCompactionThresholds,
resolveSessionCompactionPolicy,
type ResolvedSessionCompactionPolicy,
} from "@paperclipai/adapter-utils";
import type { import type {
Agent, Agent,
AdapterEnvironmentTestResult, AdapterEnvironmentTestResult,
@@ -393,6 +398,31 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const codexSearchEnabled = adapterType === "codex_local" const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
: false; : false;
const effectiveRuntimeConfig = useMemo(() => {
if (isCreate) {
return {
heartbeat: {
enabled: val!.heartbeatEnabled,
intervalSec: val!.intervalSec,
},
};
}
const mergedHeartbeat = {
...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object"
? runtimeConfig.heartbeat as Record<string, unknown>
: {}),
...overlay.heartbeat,
};
return {
...runtimeConfig,
heartbeat: mergedHeartbeat,
};
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
const sessionCompaction = useMemo(
() => resolveSessionCompactionPolicy(adapterType, effectiveRuntimeConfig),
[adapterType, effectiveRuntimeConfig],
);
const showSessionCompactionCard = Boolean(sessionCompaction.adapterSessionManagement);
return ( return (
<div className={cn("relative", cards && "space-y-6")}> <div className={cn("relative", cards && "space-y-6")}>
@@ -829,6 +859,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
numberHint={help.intervalSec} numberHint={help.intervalSec}
showNumber={val!.heartbeatEnabled} showNumber={val!.heartbeatEnabled}
/> />
{showSessionCompactionCard && (
<SessionCompactionPolicyCard
adapterType={adapterType}
resolution={sessionCompaction}
/>
)}
</div> </div>
</div> </div>
) : !isCreate ? ( ) : !isCreate ? (
@@ -851,6 +887,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
numberHint={help.intervalSec} numberHint={help.intervalSec}
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
/> />
{showSessionCompactionCard && (
<SessionCompactionPolicyCard
adapterType={adapterType}
resolution={sessionCompaction}
/>
)}
</div> </div>
<CollapsibleSection <CollapsibleSection
title="Advanced Run Policy" title="Advanced Run Policy"
@@ -938,6 +980,69 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
); );
} }
function formatSessionThreshold(value: number, suffix: string) {
if (value <= 0) return "Off";
return `${value.toLocaleString("en-US")} ${suffix}`;
}
function SessionCompactionPolicyCard({
adapterType,
resolution,
}: {
adapterType: string;
resolution: ResolvedSessionCompactionPolicy;
}) {
const { adapterSessionManagement, policy, source } = resolution;
if (!adapterSessionManagement) return null;
const adapterLabel = adapterLabels[adapterType] ?? adapterType;
const sourceLabel = source === "agent_override" ? "Agent override" : "Adapter default";
const rotationDisabled = !policy.enabled || !hasSessionCompactionThresholds(policy);
const nativeSummary =
adapterSessionManagement.nativeContextManagement === "confirmed"
? `${adapterLabel} is treated as natively managing long context, so Paperclip fresh-session rotation defaults to off.`
: adapterSessionManagement.nativeContextManagement === "likely"
? `${adapterLabel} likely manages long context itself, but Paperclip still keeps conservative rotation defaults for now.`
: `${adapterLabel} does not have verified native compaction behavior, so Paperclip keeps conservative rotation defaults.`;
return (
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-medium text-sky-50">Session compaction</div>
<span className="rounded-full border border-sky-400/30 px-2 py-0.5 text-[11px] text-sky-100">
{sourceLabel}
</span>
</div>
<p className="text-xs text-sky-100/90">
{nativeSummary}
</p>
<p className="text-xs text-sky-100/80">
{rotationDisabled
? "No Paperclip-managed fresh-session thresholds are active for this adapter."
: "Paperclip will start a fresh session when one of these thresholds is reached."}
</p>
<div className="grid grid-cols-3 gap-2 text-[11px] text-sky-100/85 tabular-nums">
<div>
<div className="text-sky-100/60">Runs</div>
<div>{formatSessionThreshold(policy.maxSessionRuns, "runs")}</div>
</div>
<div>
<div className="text-sky-100/60">Raw input</div>
<div>{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}</div>
</div>
<div>
<div className="text-sky-100/60">Age</div>
<div>{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}</div>
</div>
</div>
<p className="text-[11px] text-sky-100/75">
A large cumulative raw token total does not mean the full session is resent on every heartbeat.
{source === "agent_override" && " This agent has an explicit runtimeConfig session compaction override."}
</p>
</div>
);
}
/* ---- Internal sub-components ---- */ /* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]); const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);

View File

@@ -545,7 +545,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
{currentProjectSupportsExecutionWorkspace && ( {currentProjectSupportsExecutionWorkspace && (
<PropertyRow label="Workspace"> <PropertyRow label="Workspace">
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-2 py-1.5 w-full"> <div className="flex items-center justify-between gap-3 w-full">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm"> <div className="text-sm">
{usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"} {usesIsolatedExecutionWorkspace ? "Isolated issue checkout" : "Project primary checkout"}

View File

@@ -313,6 +313,9 @@ export function Layout() {
<BookOpen className="h-4 w-4 shrink-0" /> <BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span> <span className="truncate">Documentation</span>
</a> </a>
{health?.version && (
<span className="px-2 text-xs text-muted-foreground shrink-0">v{health.version}</span>
)}
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild> <Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link <Link
to={instanceSettingsTarget} to={instanceSettingsTarget}
@@ -363,6 +366,9 @@ export function Layout() {
<BookOpen className="h-4 w-4 shrink-0" /> <BookOpen className="h-4 w-4 shrink-0" />
<span className="truncate">Documentation</span> <span className="truncate">Documentation</span>
</a> </a>
{health?.version && (
<span className="px-2 text-xs text-muted-foreground shrink-0">v{health.version}</span>
)}
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild> <Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link <Link
to={instanceSettingsTarget} to={instanceSettingsTarget}

View File

@@ -61,6 +61,10 @@ export interface MarkdownEditorRef {
focus: () => void; focus: () => void;
} }
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/* ---- Mention detection helpers ---- */ /* ---- Mention detection helpers ---- */
interface MentionState { interface MentionState {
@@ -251,6 +255,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
try { try {
const src = await handler(file); const src = await handler(file);
setUploadError(null); setUploadError(null);
// After MDXEditor inserts the image, ensure two newlines follow it
// so the cursor isn't stuck right next to the image.
setTimeout(() => {
const current = latestValueRef.current;
const escapedSrc = escapeRegExp(src);
const updated = current.replace(
new RegExp(`(!\\[[^\\]]*\\]\\(${escapedSrc}\\))(?!\\n\\n)`, "g"),
"$1\n\n",
);
if (updated !== current) {
latestValueRef.current = updated;
ref.current?.setMarkdown(updated);
onChange(updated);
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
});
}
}, 100);
return src; return src;
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Image upload failed"; const message = err instanceof Error ? err.message : "Image upload failed";

View File

@@ -1008,8 +1008,8 @@ export function NewIssueDialog() {
</div> </div>
{currentProjectSupportsExecutionWorkspace && ( {currentProjectSupportsExecutionWorkspace && (
<div className="px-4 pb-2 shrink-0"> <div className="px-4 py-3 shrink-0">
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="text-xs font-medium">Use isolated issue checkout</div> <div className="text-xs font-medium">Use isolated issue checkout</div>
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">

View File

@@ -154,6 +154,71 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
); );
} }
function ArchiveDangerZone({
project,
onArchive,
archivePending,
}: {
project: Project;
onArchive: (archived: boolean) => void;
archivePending?: boolean;
}) {
const [confirming, setConfirming] = useState(false);
const isArchive = !project.archivedAt;
const action = isArchive ? "Archive" : "Unarchive";
return (
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
<p className="text-sm text-muted-foreground">
{isArchive
? "Archive this project to hide it from the sidebar and project selectors."
: "Unarchive this project to restore it in the sidebar and project selectors."}
</p>
{archivePending ? (
<Button size="sm" variant="destructive" disabled>
<Loader2 className="h-3 w-3 animate-spin mr-1" />
{isArchive ? "Archiving..." : "Unarchiving..."}
</Button>
) : confirming ? (
<div className="flex items-center gap-2">
<span className="text-sm text-destructive font-medium">
{action} &ldquo;{project.name}&rdquo;?
</span>
<Button
size="sm"
variant="destructive"
onClick={() => {
setConfirming(false);
onArchive(isArchive);
}}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirming(false)}
>
Cancel
</Button>
</div>
) : (
<Button
size="sm"
variant="destructive"
onClick={() => setConfirming(true)}
>
{isArchive ? (
<><Archive className="h-3 w-3 mr-1" />{action} project</>
) : (
<><ArchiveRestore className="h-3 w-3 mr-1" />{action} project</>
)}
</Button>
)}
</div>
);
}
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) { export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -420,9 +485,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
alignStart alignStart
valueClassName="space-y-2" valueClassName="space-y-2"
> >
{linkedGoals.length === 0 ? ( {linkedGoals.length > 0 && (
<span className="text-sm text-muted-foreground">None</span>
) : (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{linkedGoals.map((goal) => ( {linkedGoals.map((goal) => (
<span <span
@@ -452,7 +515,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<Button <Button
variant="outline" variant="outline"
size="xs" size="xs"
className="h-6 w-fit px-2" className={cn("h-6 w-fit px-2", linkedGoals.length > 0 && "ml-1")}
disabled={availableGoals.length === 0} disabled={availableGoals.length === 0}
> >
<Plus className="h-3 w-3 mr-1" /> <Plus className="h-3 w-3 mr-1" />
@@ -964,34 +1027,11 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<div className="text-xs font-medium text-destructive uppercase tracking-wide"> <div className="text-xs font-medium text-destructive uppercase tracking-wide">
Danger Zone Danger Zone
</div> </div>
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4"> <ArchiveDangerZone
<p className="text-sm text-muted-foreground"> project={project}
{project.archivedAt onArchive={onArchive}
? "Unarchive this project to restore it in the sidebar and project selectors." archivePending={archivePending}
: "Archive this project to hide it from the sidebar and project selectors."} />
</p>
<Button
size="sm"
variant="destructive"
disabled={archivePending}
onClick={() => {
const action = project.archivedAt ? "Unarchive" : "Archive";
const confirmed = window.confirm(
`${action} project "${project.name}"?`,
);
if (!confirmed) return;
onArchive(!project.archivedAt);
}}
>
{archivePending ? (
<><Loader2 className="h-3 w-3 animate-spin mr-1" />{project.archivedAt ? "Unarchiving..." : "Archiving..."}</>
) : project.archivedAt ? (
<><ArchiveRestore className="h-3 w-3 mr-1" />Unarchive project</>
) : (
<><Archive className="h-3 w-3 mr-1" />Archive project</>
)}
</Button>
</div>
</div> </div>
</> </>
)} )}

View File

@@ -377,21 +377,21 @@
} }
.paperclip-mdxeditor-content h1 { .paperclip-mdxeditor-content h1 {
margin: 0 0 0.9em; margin: 1.4em 0 0.9em;
font-size: 1.75em; font-size: 1.75em;
font-weight: 700; font-weight: 700;
line-height: 1.2; line-height: 1.2;
} }
.paperclip-mdxeditor-content h2 { .paperclip-mdxeditor-content h2 {
margin: 0 0 0.85em; margin: 1.3em 0 0.85em;
font-size: 1.35em; font-size: 1.35em;
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.3;
} }
.paperclip-mdxeditor-content h3 { .paperclip-mdxeditor-content h3 {
margin: 0 0 0.8em; margin: 1.2em 0 0.8em;
font-size: 1.15em; font-size: 1.15em;
font-weight: 600; font-weight: 600;
line-height: 1.35; line-height: 1.35;
@@ -585,8 +585,11 @@
color: var(--muted-foreground); color: var(--muted-foreground);
} }
.paperclip-markdown :where(h1, h2, h3, h4) { .paperclip-markdown h1,
margin-top: 1.15rem; .paperclip-markdown h2,
.paperclip-markdown h3,
.paperclip-markdown h4 {
margin-top: 1.75rem;
margin-bottom: 0.45rem; margin-bottom: 0.45rem;
color: var(--foreground); color: var(--foreground);
font-weight: 600; font-weight: 600;

View File

@@ -105,6 +105,9 @@ export const queryKeys = {
liveRuns: (companyId: string) => ["live-runs", companyId] as const, liveRuns: (companyId: string) => ["live-runs", companyId] as const,
runIssues: (runId: string) => ["run-issues", runId] as const, runIssues: (runId: string) => ["run-issues", runId] as const,
org: (companyId: string) => ["org", companyId] as const, org: (companyId: string) => ["org", companyId] as const,
skills: {
available: ["skills", "available"] as const,
},
plugins: { plugins: {
all: ["plugins"] as const, all: ["plugins"] as const,
examples: ["plugins", "examples"] as const, examples: ["plugins", "examples"] as const,

View File

@@ -10,6 +10,7 @@ import { heartbeatsApi } from "../api/heartbeats";
import { assetsApi } from "../api/assets"; import { assetsApi } from "../api/assets";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties"; import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
@@ -210,6 +211,7 @@ export function ProjectDetail() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { closePanel } = usePanel(); const { closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -284,10 +286,14 @@ export function ProjectDetail() {
{ archivedAt: archived ? new Date().toISOString() : null }, { archivedAt: archived ? new Date().toISOString() : null },
resolvedCompanyId ?? lookupCompanyId, resolvedCompanyId ?? lookupCompanyId,
), ),
onSuccess: (_, archived) => { onSuccess: (updatedProject, archived) => {
invalidateProject(); invalidateProject();
const name = updatedProject?.name ?? project?.name ?? "Project";
if (archived) { if (archived) {
navigate("/projects"); pushToast({ title: `"${name}" has been archived`, tone: "success" });
navigate("/dashboard");
} else {
pushToast({ title: `"${name}" has been unarchived`, tone: "success" });
} }
}, },
}); });
@@ -443,8 +449,24 @@ export function ProjectDetail() {
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />; return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
} }
// Redirect bare /projects/:id to /projects/:id/issues // Redirect bare /projects/:id to cached tab or default /issues
if (routeProjectRef && activeTab === null) { if (routeProjectRef && activeTab === null) {
let cachedTab: string | null = null;
if (project?.id) {
try { cachedTab = localStorage.getItem(`paperclip:project-tab:${project.id}`); } catch {}
}
if (cachedTab === "overview") {
return <Navigate to={`/projects/${canonicalProjectRef}/overview`} replace />;
}
if (cachedTab === "configuration") {
return <Navigate to={`/projects/${canonicalProjectRef}/configuration`} replace />;
}
if (cachedTab === "budget") {
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
}
if (isProjectPluginTab(cachedTab)) {
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
}
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />; return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
} }
@@ -453,6 +475,10 @@ export function ProjectDetail() {
if (!project) return null; if (!project) return null;
const handleTabChange = (tab: ProjectTab) => { const handleTabChange = (tab: ProjectTab) => {
// Cache the active tab per project
if (project?.id) {
try { localStorage.setItem(`paperclip:project-tab:${project.id}`, tab); } catch {}
}
if (isProjectPluginTab(tab)) { if (isProjectPluginTab(tab)) {
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`); navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
return; return;
@@ -527,8 +553,8 @@ export function ProjectDetail() {
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}> <Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
<PageTabBar <PageTabBar
items={[ items={[
{ value: "list", label: "Issues" },
{ value: "overview", label: "Overview" }, { value: "overview", label: "Overview" },
{ value: "list", label: "List" },
{ value: "configuration", label: "Configuration" }, { value: "configuration", label: "Configuration" },
{ value: "budget", label: "Budget" }, { value: "budget", label: "Budget" },
...pluginTabItems.map((item) => ({ ...pluginTabItems.map((item) => ({