mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 01:45:00 +00:00
* feat(server): add an in-memory store for agent background work Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(claude): report background tasks as background work Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(server): track agent background work in the agent manager Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(protocol): add agent background work messages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(server): serve and push agent background work Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(client): expose agent background work on agent handles Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(sdk): document agent background work Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(claude): cover background shells in background work end to end Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(client): resync background work subscriptions on every snapshot subscribe() now lists the agent's background work each time the event observation is established, so the listener starts with the current list and catches up after a reconnect. A list answered after a newer update is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server): answer background work lists without loading dormant agents Background work lives only in memory, so a stored agent that is not loaded has none. The list handler now answers [] from storage instead of resuming the agent, and still rejects unknown, internal, and archived agents. A new test pins that old apps never receive agent.background_work.update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server): read Claude background tasks defensively The user's claude binary can differ from the SDK typings, so a task's description is read as unknown and an empty or missing one becomes null. Drop the monitor kind: Claude Monitor watches report as local_bash, so kind is "shell" or "other" today. The store now copies only the wire fields of each input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(server): prove internal background work was emitted before checking globals Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(sdk): describe background work resync and feature gating Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(claude): cover background work reported by a retired query The daemon stops reading a Claude query it no longer owns, so work that a retired process still had queued cannot reach the background work list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(client): let background work subscribers handle a failed list read When the list read that follows each subscription snapshot failed, the error was only logged and the caller got neither the list nor a failure. subscribe() now takes an optional onError that receives it; without one the error is still logged. The read is not retried, since the next reconnect reads the list again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(agents): show background work as active Keep background activity visible after a foreground turn ends while preserving independent send and interrupt behavior. * fix(agents): protect background work subscriptions Keep internal work out of public updates and restore observation after a subscription failure. * feat(agents): show Codex and OpenCode background work Keep provider-owned shell activity visible after a turn ends while leaving foreground turn controls independent. * feat(agents): stop individual background work Wait for provider confirmation so stopping one background item leaves sibling work and foreground turn controls independent. * refactor(agents): name background stop parameters * test(website): isolate browser tests from live metadata * test(agents): clarify background stop journeys * fix(sdk): report background work subscription failures Report setup failures so subscribers can replace an observation that the host released. * fix(app): restore background work after reconnect Wait for the host handshake before selecting the retained client so an offline pane starts observing when the connection returns. * fix(agents): retain background shells across rewind Preserve known live shell ownership when conversation history is rewound, and revoke it when the provider runtime exits. * fix(website): launch test seeder across platforms Invoke Wrangler through Node so local website test startup does not depend on an executable npm command shim. * feat(agents): clarify background task details and tracks Keep launch descriptions and commands distinct so background work remains identifiable, and allow composer tracks to scroll when space is limited. * fix(agents): preserve active background work during archive Keep background-running children out of bulk cleanup and warn before an individual archive. Preserve first taps on composer tracks while the keyboard is open. * fix(codex): retain background terminals across rewind Keep live terminal inventories attached to their originating threads so a fork does not hide or misroute their stop actions. * fix(codex): scope background terminal identities to threads Prevent commands on separate threads with the same provider process ID from sharing a stop target. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
|
import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state";
|
|
|
|
/**
|
|
* Derives the project key for grouping agents.
|
|
* For worktrees, returns the parent repo path.
|
|
* For regular repos/directories, returns the cwd.
|
|
*/
|
|
export function deriveProjectKey(cwd: string): string {
|
|
const worktreeMarker = ".paseo/worktrees/";
|
|
const idx = cwd.indexOf(worktreeMarker);
|
|
if (idx !== -1) {
|
|
// Return parent repo path (before .paseo/worktrees/)
|
|
return cwd.slice(0, idx).replace(/\/$/, "");
|
|
}
|
|
return cwd;
|
|
}
|
|
|
|
/**
|
|
* Produces a stable grouping key from a git remote URL.
|
|
*
|
|
* Waterfall:
|
|
* - Prefer a GitHub key (normalizes SSH/HTTPS to the same key).
|
|
* - Fallback to a generic host/path key (still normalized across SSH/HTTPS).
|
|
*/
|
|
export function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
|
if (!remoteUrl) {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = remoteUrl.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
// Support the common forms:
|
|
// - git@github.com:owner/repo.git
|
|
// - https://github.com/owner/repo(.git)
|
|
// - ssh://git@github.com/owner/repo(.git)
|
|
let host: string | null = null;
|
|
let path: string | null = null;
|
|
|
|
// SSH scp-like form: user@host:owner/repo(.git)
|
|
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
|
|
if (scpLike) {
|
|
host = scpLike[1] ?? null;
|
|
path = scpLike[2] ?? null;
|
|
} else if (trimmed.includes("://")) {
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
host = parsed.hostname || null;
|
|
path = parsed.pathname ? parsed.pathname.replace(/^\//, "") : null;
|
|
} catch {
|
|
// Fall through to best-effort parsing below.
|
|
}
|
|
}
|
|
|
|
if (!host || !path) {
|
|
return null;
|
|
}
|
|
|
|
let cleanedPath = path.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
if (cleanedPath.endsWith(".git")) {
|
|
cleanedPath = cleanedPath.slice(0, -4);
|
|
}
|
|
|
|
// Best-effort: owner/repo is the common case.
|
|
// If the path is longer (e.g. groups/subgroups/repo), still keep it.
|
|
if (!cleanedPath.includes("/")) {
|
|
return null;
|
|
}
|
|
|
|
const cleanedHost = host.toLowerCase();
|
|
|
|
// GitHub normalization: treat github.com as a special "well-known" host to
|
|
// match the intended UX: group by repo even across different local worktrees.
|
|
if (cleanedHost === "github.com") {
|
|
return `remote:github.com/${cleanedPath}`;
|
|
}
|
|
|
|
return `remote:${cleanedHost}/${cleanedPath}`;
|
|
}
|
|
|
|
/**
|
|
* Extracts the repo name from a git remote URL.
|
|
* Examples:
|
|
* git@github.com:anthropics/claude-code.git -> anthropics/claude-code
|
|
* https://github.com/anthropics/claude-code.git -> anthropics/claude-code
|
|
* https://github.com/anthropics/claude-code -> anthropics/claude-code
|
|
*/
|
|
export function parseRepoNameFromRemoteUrl(remoteUrl: string | null): string | null {
|
|
if (!remoteUrl) {
|
|
return null;
|
|
}
|
|
|
|
let cleaned = remoteUrl;
|
|
|
|
// Handle SSH format: git@github.com:owner/repo.git
|
|
if (cleaned.startsWith("git@")) {
|
|
const colonIdx = cleaned.indexOf(":");
|
|
if (colonIdx !== -1) {
|
|
cleaned = cleaned.slice(colonIdx + 1);
|
|
}
|
|
}
|
|
// Handle HTTPS format: https://github.com/owner/repo.git
|
|
else if (cleaned.includes("://")) {
|
|
const urlPath = cleaned.split("://")[1];
|
|
if (urlPath) {
|
|
// Remove host (e.g., github.com/)
|
|
const slashIdx = urlPath.indexOf("/");
|
|
if (slashIdx !== -1) {
|
|
cleaned = urlPath.slice(slashIdx + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove .git suffix
|
|
if (cleaned.endsWith(".git")) {
|
|
cleaned = cleaned.slice(0, -4);
|
|
}
|
|
|
|
// Should be in format owner/repo now
|
|
if (cleaned.includes("/")) {
|
|
return cleaned;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extracts just the repo name (without owner) from a remote URL.
|
|
* Examples:
|
|
* git@github.com:anthropics/claude-code.git -> claude-code
|
|
*/
|
|
export function parseRepoShortNameFromRemoteUrl(remoteUrl: string | null): string | null {
|
|
const fullName = parseRepoNameFromRemoteUrl(remoteUrl);
|
|
if (!fullName) {
|
|
return null;
|
|
}
|
|
const parts = fullName.split("/");
|
|
return parts[parts.length - 1] || null;
|
|
}
|
|
|
|
/**
|
|
* Extracts the project name from a path (last segment).
|
|
*/
|
|
export function deriveProjectName(projectKey: string): string {
|
|
const githubRemotePrefix = "remote:github.com/";
|
|
if (projectKey.startsWith(githubRemotePrefix)) {
|
|
return projectKey.slice(githubRemotePrefix.length) || projectKey;
|
|
}
|
|
const segments = projectKey.split("/").filter(Boolean);
|
|
return segments[segments.length - 1] || projectKey;
|
|
}
|
|
|
|
/**
|
|
* Formats a project name for display in the UI.
|
|
*
|
|
* - GitHub remotes show owner/repo
|
|
* - Other remotes show the remote path when possible
|
|
* - Local projects prefer the provided projectName, then fallback to cwd tail
|
|
*/
|
|
export function deriveProjectDisplayName(input: {
|
|
projectKey: string;
|
|
projectName: string;
|
|
}): string {
|
|
const githubPrefix = "remote:github.com/";
|
|
if (input.projectKey.startsWith(githubPrefix)) {
|
|
return input.projectKey.slice(githubPrefix.length);
|
|
}
|
|
|
|
if (input.projectKey.startsWith("remote:")) {
|
|
const withoutPrefix = input.projectKey.slice("remote:".length);
|
|
const slashIdx = withoutPrefix.indexOf("/");
|
|
if (slashIdx >= 0) {
|
|
const remotePath = withoutPrefix.slice(slashIdx + 1).trim();
|
|
if (remotePath.length > 0) {
|
|
return remotePath;
|
|
}
|
|
}
|
|
return withoutPrefix;
|
|
}
|
|
|
|
const trimmedProjectName = input.projectName.trim();
|
|
if (trimmedProjectName.length > 0) {
|
|
return trimmedProjectName;
|
|
}
|
|
|
|
const normalized = input.projectKey.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
const segments = normalized.split("/").filter(Boolean);
|
|
return segments[segments.length - 1] ?? input.projectKey;
|
|
}
|
|
|
|
/**
|
|
* Determines the date group label for an agent based on lastActivityAt.
|
|
*/
|
|
export function deriveDateGroup(lastActivityAt: Date): string {
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
|
const activityDate = new Date(
|
|
lastActivityAt.getFullYear(),
|
|
lastActivityAt.getMonth(),
|
|
lastActivityAt.getDate(),
|
|
);
|
|
|
|
if (activityDate.getTime() >= today.getTime()) {
|
|
return "Recent";
|
|
}
|
|
if (activityDate.getTime() >= yesterday.getTime()) {
|
|
return "Yesterday";
|
|
}
|
|
|
|
const diffTime = today.getTime() - activityDate.getTime();
|
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays <= 7) {
|
|
return "This week";
|
|
}
|
|
if (diffDays <= 30) {
|
|
return "This month";
|
|
}
|
|
return "Older";
|
|
}
|
|
|
|
export interface ProjectGroup {
|
|
projectKey: string;
|
|
projectName: string;
|
|
agents: AggregatedAgent[];
|
|
/** Number of truly active agents (running, needs input, or requires attention) */
|
|
activeCount: number;
|
|
/** Total agents before any limit was applied */
|
|
totalCount: number;
|
|
}
|
|
|
|
export interface DateGroup {
|
|
label: string;
|
|
agents: AggregatedAgent[];
|
|
}
|
|
|
|
export interface GroupedAgents {
|
|
activeGroups: ProjectGroup[];
|
|
inactiveGroups: DateGroup[];
|
|
}
|
|
|
|
const ACTIVE_GRACE_PERIOD_MS = 2 * 24 * 60 * 60 * 1000; // 2 days (temporary for screenshots)
|
|
|
|
interface GroupAgentsOptions {
|
|
/**
|
|
* Optional function to read a remote URL for an agent.
|
|
* If present and a remote URL is available, agents are grouped by remote.
|
|
*/
|
|
getRemoteUrl?: (agent: AggregatedAgent) => string | null;
|
|
}
|
|
|
|
const MAX_INACTIVE_PER_PROJECT = 5;
|
|
|
|
/**
|
|
* Groups agents into active (by project) and inactive (by date) sections.
|
|
* Active = running, needs input, requires attention, or had activity within the grace period.
|
|
*
|
|
* Within each project group:
|
|
* - All truly active agents (running/needs input/requires attention) are always shown
|
|
* - Recently active (within grace period but not truly active) are limited to MAX_INACTIVE_PER_PROJECT
|
|
*/
|
|
function activityStatus(agent: AggregatedAgent): AggregatedAgent["status"] {
|
|
if (agent.turn.phase === "open") return "running";
|
|
return agent.status === "running" ? "idle" : agent.status;
|
|
}
|
|
|
|
function isAgentTrulyActive(agent: AggregatedAgent): boolean {
|
|
return isSidebarActiveAgent({
|
|
status: activityStatus(agent),
|
|
backgroundWork: agent.backgroundWork,
|
|
requiresAttention: agent.requiresAttention,
|
|
attentionReason: agent.attentionReason,
|
|
pendingPermissionCount: agent.pendingPermissionCount,
|
|
});
|
|
}
|
|
|
|
function partitionAgentsByActivity(
|
|
agents: AggregatedAgent[],
|
|
now: number,
|
|
): { activeAgents: AggregatedAgent[]; inactiveAgents: AggregatedAgent[] } {
|
|
const activeAgents: AggregatedAgent[] = [];
|
|
const inactiveAgents: AggregatedAgent[] = [];
|
|
|
|
for (const agent of agents) {
|
|
if (agent.archivedAt) {
|
|
inactiveAgents.push(agent);
|
|
continue;
|
|
}
|
|
|
|
const isRecentlyActive = now - agent.lastActivityAt.getTime() < ACTIVE_GRACE_PERIOD_MS;
|
|
if (isAgentTrulyActive(agent) || isRecentlyActive) {
|
|
activeAgents.push(agent);
|
|
} else {
|
|
inactiveAgents.push(agent);
|
|
}
|
|
}
|
|
|
|
return { activeAgents, inactiveAgents };
|
|
}
|
|
|
|
interface ProjectActivityBucket {
|
|
trulyActive: AggregatedAgent[];
|
|
recentlyActive: AggregatedAgent[];
|
|
}
|
|
|
|
function buildProjectActivityMap(
|
|
activeAgents: AggregatedAgent[],
|
|
options: GroupAgentsOptions | undefined,
|
|
): Map<string, ProjectActivityBucket> {
|
|
const projectMap = new Map<string, ProjectActivityBucket>();
|
|
for (const agent of activeAgents) {
|
|
const remoteKey = deriveRemoteProjectKey(options?.getRemoteUrl?.(agent) ?? null);
|
|
const projectKey = remoteKey ?? deriveProjectKey(agent.cwd);
|
|
const existing = projectMap.get(projectKey) || { trulyActive: [], recentlyActive: [] };
|
|
|
|
if (isAgentTrulyActive(agent)) {
|
|
existing.trulyActive.push(agent);
|
|
} else {
|
|
existing.recentlyActive.push(agent);
|
|
}
|
|
|
|
projectMap.set(projectKey, existing);
|
|
}
|
|
return projectMap;
|
|
}
|
|
|
|
function byLastActivityDescending(a: AggregatedAgent, b: AggregatedAgent): number {
|
|
return b.lastActivityAt.getTime() - a.lastActivityAt.getTime();
|
|
}
|
|
|
|
function buildActiveProjectGroups(projectMap: Map<string, ProjectActivityBucket>): ProjectGroup[] {
|
|
const activeGroups: ProjectGroup[] = [];
|
|
for (const [projectKey, { trulyActive, recentlyActive }] of projectMap) {
|
|
trulyActive.sort(byLastActivityDescending);
|
|
recentlyActive.sort(byLastActivityDescending);
|
|
|
|
const limitedRecentlyActive = recentlyActive.slice(0, MAX_INACTIVE_PER_PROJECT);
|
|
const combinedAgents = [...trulyActive, ...limitedRecentlyActive];
|
|
combinedAgents.sort(byLastActivityDescending);
|
|
|
|
activeGroups.push({
|
|
projectKey,
|
|
projectName: deriveProjectName(projectKey),
|
|
agents: combinedAgents,
|
|
activeCount: trulyActive.length,
|
|
totalCount: trulyActive.length + recentlyActive.length,
|
|
});
|
|
}
|
|
|
|
activeGroups.sort((a, b) => {
|
|
const aRecent = a.agents[0]?.lastActivityAt.getTime() ?? 0;
|
|
const bRecent = b.agents[0]?.lastActivityAt.getTime() ?? 0;
|
|
return bRecent - aRecent;
|
|
});
|
|
|
|
return activeGroups;
|
|
}
|
|
|
|
function buildInactiveDateGroups(inactiveAgents: AggregatedAgent[]): DateGroup[] {
|
|
const dateMap = new Map<string, AggregatedAgent[]>();
|
|
for (const agent of inactiveAgents) {
|
|
const dateLabel = deriveDateGroup(agent.lastActivityAt);
|
|
const existing = dateMap.get(dateLabel) || [];
|
|
existing.push(agent);
|
|
dateMap.set(dateLabel, existing);
|
|
}
|
|
|
|
const dateOrder = ["Recent", "Yesterday", "This week", "This month", "Older"];
|
|
const inactiveGroups: DateGroup[] = [];
|
|
for (const label of dateOrder) {
|
|
const dateAgents = dateMap.get(label);
|
|
if (dateAgents && dateAgents.length > 0) {
|
|
dateAgents.sort(byLastActivityDescending);
|
|
inactiveGroups.push({ label, agents: dateAgents });
|
|
}
|
|
}
|
|
return inactiveGroups;
|
|
}
|
|
|
|
export function groupAgents(
|
|
agents: AggregatedAgent[],
|
|
options?: GroupAgentsOptions,
|
|
): GroupedAgents {
|
|
const { activeAgents, inactiveAgents } = partitionAgentsByActivity(agents, Date.now());
|
|
const projectMap = buildProjectActivityMap(activeAgents, options);
|
|
const activeGroups = buildActiveProjectGroups(projectMap);
|
|
const inactiveGroups = buildInactiveDateGroups(inactiveAgents);
|
|
return { activeGroups, inactiveGroups };
|
|
}
|