Merge public-gh/master into paperclip-company-import-export
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
175
packages/adapter-utils/src/session-compaction.ts
Normal file
175
packages/adapter-utils/src/session-compaction.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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[]>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
71
scripts/kill-dev.sh
Executable 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."
|
||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
10
server/src/version.ts
Normal 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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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} “{project.name}”?
|
||||||
|
</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user