diff --git a/AGENTS.md b/AGENTS.md index 13c6ef0c00..b4eff798e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,23 @@ app/api/ sessions/[id]/state/route.ts GET live wrapper state while running sessions/[id]/auto-name/route.ts POST generate a session title sessions/search/route.ts GET session search - agent/new/route.ts POST { cwd, type: prompt|ensure_session (start only), message?, toolNames?, provider?, modelId?, thinkingLevel? } + agent-ops/tasks/[id]/route.ts POST steer a running task | DELETE cancel + agent-ops/triggers/route.ts GET [?agent=] list triggers (hasWebhookSecret, never the secret) | POST create; webhook: true returns the generated secret once + agent-ops/triggers/[id]/route.ts PATCH enabled/edit (re-pins on profile or cwd change) | DELETE + agent-ops/triggers/[id]/secret/route.ts POST rotate the webhook secret, returned once + agent-ops/triggers/[id]/hook/route.ts POST webhook ingestion (secret header, exempt from the session in proxy.ts) + agent-ops/memory/route.ts GET [?agent=] pi-mem0 staged memories (approval queue) + agent-ops/memory/[id]/decision/route.ts POST { approved } write a staged memory's decision file + agents/route.ts GET long-term agents (name, avatar, running, unread) | POST create + agents/[name]/route.ts GET detail | PATCH profile (409 agent_running while the thread runs) | DELETE to .trash + agents/[name]/thread/route.ts POST open or create the pinned thread + agents/[name]/thread/reset/route.ts POST archive the thread to the trash and start a fresh one (409 agent_running) + agents/mcp-servers/route.ts GET names of the global pi-mcp-adapter servers (never their config) + agents/[name]/read/route.ts POST { entryId } set lastReadEntryId + agents/[name]/tasks/route.ts GET the agent's tasks | POST { prompt } queue a thread task (20 000-char cap) + agents/[name]/memory/route.ts GET the agent's recent memories + agents/[name]/memory/forget/route.ts POST request a forget + agent/new/route.ts POST { cwd, type: prompt|ensure_session (start only), message?, toolNames?, provider?, modelId?, thinkingLevel?, agentProfile? } agent/[id]/route.ts GET state | POST any command agent/[id]/events/route.ts GET SSE stream agent/running/route.ts GET running session ids @@ -101,9 +117,32 @@ lib/ global-settings-file.ts locked read-modify-write of global settings.json (SettingsManager's lock) regular-file.ts readRegularFileText(): non-blocking read of a regular file only, optional size cap default-preferences.ts write defaultModel/defaultThinkingLevel; detect project shadowing + deferred-provider-models.ts models of providers an extension registers only at session_start, re-added to listings enabled-models.ts pure minimal-edit engine for the enabledModels pattern list enabled-models-runtime.ts SDK adapter for enabledModels: pattern resolution, provider kinds, settings IO subagent-settings.ts read/write ~/.pi/agent/agents/settings.json + agents/registry.ts long-term agent files: profile, space state, home, trash; AGENT_NAME_RE + agents/mcp-access.ts per-agent MCP allowlist: lists global adapter servers, writes /.pi/mcp-adapter.json + agents/registry-response.ts registry errors to HTTP responses + agents/thread.ts pinned trusted thread: ensureThread, openThread, unread count + agents/agent-view.ts list/detail views, canEditProfile, unread helpers (client-safe) + agents/events.ts pi-web:agent-event entries: builders, guard, UI mapping, folded-prompt indexes (client-safe) + agents/queue.ts isolated / thread task selectors for the two runners + agents/thread-run.ts thread event run: open, wait idle, card, prompt + agents/agent-notify.ts agent_notify push tool, trusted threads only + agents/memory.ts mem0 snapshot reader + forget requests + agent-ops/task-store.ts JSON task files: immutable terminal states, wx claim lock, recovery + agent-ops/runner.ts FIFO runner: slots, one maxRunMs deadline, fire-and-forget abort + agent-ops/prompt-run.ts prompt send (not awaited), done from events, trigger tool check + agent-ops/spawn.ts startAgentProfileRun: session start for a profile task + agent-ops/kick.ts kickRunner (single entry point), recoverOnce + agent-ops/trigger-store.ts trigger configs (CRUD), closed tool allowlist, profile pin, triggerRunPin + agent-ops/scheduler.ts 60 s tick: scheduled fires, payload ingestion, fire-token purge, task retention + agent-ops/webhook.ts handleHook: fail-closed webhook (secret digest, dedicated throttle, 64 KB stream cap) + agent-ops/hook-path.ts exact hook path the proxy lets through without a session + the secret header name (client-safe) + agent-ops/trigger-api.ts trigger create/patch/rotate/delete logic behind the routes, toPublicTrigger (secret never leaves) + agent-ops/redact.ts verbatim pi-mem0 secret redaction + truncate (server-only) + agent-ops/memory-review.ts pi-mem0 staging reader + decision writer (never the mem0 store) file-access.ts allowed file roots for /api/files and worktrees linked-directory.ts directory links leading outside the allowed roots + the allow-link check file-paths.ts client/server path encoding helpers @@ -159,6 +198,15 @@ components/ OAuthPastePanel.tsx paste box for a sign-in's redirected address or code (Models, MCP) ProjectTrustDialog.tsx trust confirmation listing the project's MCP servers AgentsConfig.tsx built-in subagent toggle + agent profile editor + agents/AgentRail.tsx long-term agent rail: avatars, running dot, unread badge + agents/AgentAvatar.tsx agent avatar (color + glyph) + agents/NewAgentDialog.tsx create a long-term agent + agents/AgentProfileDialog.tsx edit a long-term agent's profile + agents/AgentEventCard.tsx event card in the thread (orange schedule/task, purple webhook) + agents/QueueTaskDialog.tsx queue a task for an agent's thread + agents/AgentMemoryRecent.tsx recent memories with forget + agents/AgentSpaceLeft.tsx / AgentSpaceRight.tsx agent view panels (home files; profile and status) + agents/dialog-styles.ts shared styles of the agent dialogs PluginsConfig.tsx Settings › Plugins: installed package plugins SkillsConfig.tsx Settings › Skills: loaded, search, install McpConfig.tsx Settings › MCP: servers, switches, exposure, remove/undo, Test, sign-in, Code mode, trust @@ -175,7 +223,7 @@ components/ hooks/ useAgentSession.ts messages, streaming, SSE, fork/navigate, reconciliation; built-in slash commands (/session, bare /mcp) useAudio.ts completion sound + AudioContext unlock - useDragDrop.ts shared drag/drop state + useDragDrop.ts shared drag/drop state (accept predicate, default image-only; skips dropped folders) useIsMobile.ts responsive breakpoint useKeyboardShortcuts.ts Esc stops the running agent unless a field or nearer handler took it; Ctrl+Alt+N useTheme.ts theme state @@ -193,10 +241,12 @@ Design decisions and traps live in `docs/agents/`, one note per area. Read every - [mcp-settings.md](docs/agents/mcp-settings.md): Settings › MCP reads without running anything, masking, the trust dialog's server list, row states, notices, Code mode choice, trust from Settings, Escape stacking, every `mcp.json` write and undo. Files: `app/api/mcp/route.ts`, `app/api/project-trust/route.ts`, `lib/mcp-config-read.ts`, `lib/mcp-config-file.ts`, `lib/mcp-undo.ts`, `lib/mcp-secrets.ts`, `lib/mcp-server-display.ts`, `lib/mcp-json-error.ts`, `lib/project-trust.ts`, `lib/regular-file.ts`, `lib/stacked-dialog.ts`, `lib/settings-navigation.ts`, `components/McpConfig.tsx`, `components/mcp-config-helpers.ts`, `components/ProjectTrustDialog.tsx`, `components/SettingsPanel.tsx`. - [mcp-test-sign-in.md](docs/agents/mcp-test-sign-in.md): Settings › MCP Test (route checks, bounded connection, `!command` queue, redaction, status store) and OAuth sign-in / sign-out. Files: `app/api/mcp/test/**`, `app/api/mcp/sign-in/**`, `lib/mcp-test.ts`, `lib/mcp-entry-request.ts`, `lib/mcp-status.ts`, `lib/mcp-sign-in.ts`, `lib/mcp-sign-out.ts`, `components/McpSignIn.tsx`, `components/mcp-sign-in-helpers.ts`, `components/OAuthPastePanel.tsx`. - [mcp-add.md](docs/agents/mcp-add.md): Settings › MCP add (paste re-parsed on the server, host-variable confirmation, literal secrets kept global, fresh-folder trust, the add pane) and the paste importer's escaping and grammars. Files: `lib/mcp-add.ts`, `lib/mcp-import*.ts`, `lib/shell-words.ts`, fresh-folder trust in `lib/project-trust.ts`, `components/McpAddServer.tsx`, `components/mcp-add-helpers.ts`, the `add` action of `app/api/mcp/route.ts`. -- [models.md](docs/agents/models.md): default model and reasoning level, mid-run reasoning changes, remote provider catalogs, `enabledModels` scoping and minimal edits, provider auth listing and credentials. Files: `app/api/models/**`, `app/api/models-config/**`, `app/api/auth/**`, `lib/default-preferences.ts`, `lib/model-scope.ts`, `lib/enabled-models*.ts`, `lib/model-catalog-refresh.ts`, `lib/provider-listing*.ts`, `components/ModelsConfig.tsx`, `components/EnabledModelsSection.tsx`, `components/ModelSelector.tsx`, `components/SelectorRow.tsx`. +- [models.md](docs/agents/models.md): default model and reasoning level, providers registered at session_start, mid-run reasoning changes, remote provider catalogs, `enabledModels` scoping and minimal edits, provider auth listing and credentials. Files: `app/api/models/**`, `app/api/models-config/**`, `app/api/auth/**`, `lib/default-preferences.ts`, `lib/model-scope.ts`, `lib/enabled-models*.ts`, `lib/model-catalog-refresh.ts`, `lib/deferred-provider-models.ts`, `lib/provider-listing*.ts`, `components/ModelsConfig.tsx`, `components/EnabledModelsSection.tsx`, `components/ModelSelector.tsx`, `components/SelectorRow.tsx`. - [files-and-access.md](docs/agents/files-and-access.md): worktrees and project grouping, the file access allow-list (the `/api/files` security boundary), file tree visibility, web password throttling. Files: `app/api/files/**`, `app/api/cwd/**`, `app/api/worktrees/**`, `app/api/file-index/**`, `app/api/web-auth/**`, `proxy.ts`, `lib/path-security.ts`, `lib/file-access.ts`, `lib/linked-directory.ts`, `lib/session-file-references*.ts`, `lib/file-tree-visibility.ts`, `lib/worktree.ts`, `lib/paths.ts`, `lib/auth-throttle.ts`, `components/FileExplorer.tsx`. - [settings-ui.md](docs/agents/settings-ui.md): Plugins and Skills routes, sidebar group switches, the shared `SettingsUi` blocks every settings panel and add pane uses. Files: `app/api/plugins/**`, `app/api/skills/**`, `components/SettingsUi.tsx`, `components/settings-ui-helpers.ts`, `components/SkillsConfig.tsx`, `components/PluginsConfig.tsx`; also before adding a settings section or add pane. - [subagents.md](docs/agents/subagents.md): the built-in subagent setting, profiles and their files, run status, completion notifications. Files: `lib/subagent*.ts`, `app/api/subagents/**`, `components/AgentsConfig.tsx`. +- [agent-ops.md](docs/agents/agent-ops.md): the task store and FIFO runner, cancel/steer ordering, the single deadline, the unawaited prompt send, trigger tool check, the two runners. Files: `lib/agent-ops/**`, `lib/agents/{queue,thread-run}.ts`, `app/api/agent-ops/**`, `components/agents/{AgentTasks,AgentTriggers,TriggerDialog,TriggerSecretDialog,AgentMemory}.tsx`, `task-view.ts`, `trigger-view.ts`. +- [long-term-agents.md](docs/agents/long-term-agents.md): agent data model (profile, space, home, trash), the pinned trusted thread and its re-snapshot rule, unread marker, rail and `?agent=` navigation, event cards, the per-agent queue, trigger binding, `agent_notify`. Files: `lib/agents/**`, `app/api/agents/**`, `components/agents/*`, the agent parts of `components/AppShell.tsx`, `components/ChatWindow.tsx`, `components/MessageView.tsx`, `lib/rpc-manager.ts`, `lib/session-reader.ts`, `lib/web-push.ts`, `lib/initial-navigation.ts`. - [client-platform.md](docs/agents/client-platform.md): mobile software keyboard and viewport height, completion sound. Files: `hooks/useViewportHeight.ts`, `hooks/useAudio.ts`, the keyboard-open CSS. --- @@ -230,3 +280,7 @@ Location: `~/.pi/agent/sessions//_.jsonl` --accent --user-bg --tool-bg --font-mono ``` + +## Learned + +- Running the test suite from inside a pi process (agent, subagent): prefix it with `env -i PATH="$PATH" HOME="$HOME" LANG="$LANG"` — inherited `PI_PACKAGE_DIR` & co. break ~44-97 MCP/SDK-resolution tests. diff --git a/app/api/agent-ops/memory/[id]/decision/route.ts b/app/api/agent-ops/memory/[id]/decision/route.ts new file mode 100644 index 0000000000..07636a5e4d --- /dev/null +++ b/app/api/agent-ops/memory/[id]/decision/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { MemoryReviewError, writeDecision } from "@/lib/agent-ops/memory-review"; + +export const dynamic = "force-dynamic"; + +// POST /api/agent-ops/memory/[id]/decision body: { approved } - Approve or reject a staged memory. +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + let body: { approved?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + if (typeof body.approved !== "boolean") { + return NextResponse.json({ error: "approved must be a boolean" }, { status: 400 }); + } + try { + writeDecision((await params).id, body.approved); + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof MemoryReviewError) { + return NextResponse.json({ error: error.message }, { status: error.code === "conflict" ? 409 : 404 }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + } +} diff --git a/app/api/agent-ops/memory/route.ts b/app/api/agent-ops/memory/route.ts new file mode 100644 index 0000000000..bcfd753c9d --- /dev/null +++ b/app/api/agent-ops/memory/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { listSubagentProfiles } from "@/lib/subagents"; +import { listStagedFacts } from "@/lib/agent-ops/memory-review"; + +export const dynamic = "force-dynamic"; + +// GET /api/agent-ops/memory[?agent=] - Memories staged by pi-mem0, waiting for or carrying a decision. +export async function GET(req: Request) { + try { + const agent = new URL(req.url).searchParams.get("agent"); + const staged = listStagedFacts(); + const longTerm = new Set(listSubagentProfiles(process.cwd()).filter((p) => p.longTerm).map((p) => p.name)); + // Without ?agent= this is the legacy panel's queue: long-term agents review their memory in their own view. + const facts = agent === null ? staged.filter((fact) => !longTerm.has(fact.agent)) : staged.filter((fact) => fact.agent === agent); + return NextResponse.json({ facts }, { headers: { "Cache-Control": "no-store" } }); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + } +} diff --git a/app/api/agent-ops/tasks/[id]/route.ts b/app/api/agent-ops/tasks/[id]/route.ts new file mode 100644 index 0000000000..be4f23b57c --- /dev/null +++ b/app/api/agent-ops/tasks/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { getRpcSession } from "@/lib/rpc-manager"; +import { recoverOnce } from "@/lib/agent-ops/kick"; +import { cancelTask, getTask, updateTask } from "@/lib/agent-ops/task-store"; + +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ id: string }> }; + +// POST /api/agent-ops/tasks/[id] body: { message } - Steer a running task's session. +export async function POST(req: Request, { params }: Context) { + recoverOnce(); + const task = getTask((await params).id); + if (!task) return NextResponse.json({ error: "Task not found" }, { status: 404 }); + let body: { message?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + if (typeof body.message !== "string" || !body.message.trim()) { + return NextResponse.json({ error: "message is required" }, { status: 400 }); + } + const session = task.status === "running" && task.sessionId ? getRpcSession(task.sessionId) : undefined; + if (!session) return NextResponse.json({ error: "Task is not running a session" }, { status: 409 }); + try { + await session.send({ type: "steer", message: body.message }); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + } + return NextResponse.json({ success: true }); +} + +// DELETE /api/agent-ops/tasks/[id] - Cancel a queued or running task. +export async function DELETE(_req: Request, { params }: Context) { + recoverOnce(); + const { id } = await params; + const task = getTask(id); + if (!task) return NextResponse.json({ error: "Task not found" }, { status: 404 }); + let current = task; + if (task.status === "queued") { + try { cancelTask(id); } catch { /* raced with another writer: the re-read below decides */ } + current = getTask(id) ?? task; // the runner may have claimed it meanwhile + } + if (current.status === "running") { + // Status first: a task still starting has no session to abort yet; the runner re-reads + // the status right after start() and aborts the session itself. + try { + updateTask(id, { status: "cancelled", completedAt: new Date().toISOString() }); + } catch { /* already terminal: answer with the current status */ } + if (current.sessionId) await getRpcSession(current.sessionId)?.send({ type: "abort" }).catch(() => {}); + } + return NextResponse.json({ task: getTask(id) }); +} diff --git a/app/api/agent-ops/triggers/[id]/hook/route.ts b/app/api/agent-ops/triggers/[id]/hook/route.ts new file mode 100644 index 0000000000..c8aac64b7c --- /dev/null +++ b/app/api/agent-ops/triggers/[id]/hook/route.ts @@ -0,0 +1,11 @@ +import { kickRunner } from "@/lib/agent-ops/kick"; +import { handleHook } from "@/lib/agent-ops/webhook"; + +export const dynamic = "force-dynamic"; + +// POST /api/agent-ops/triggers/:id/hook - Webhook ingestion. Exempt from the browser session in proxy.ts: +// authenticated by the trigger's shared secret (header x-agent-ops-secret) behind its own throttle. +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return handleHook(req, id, kickRunner); +} diff --git a/app/api/agent-ops/triggers/[id]/route.ts b/app/api/agent-ops/triggers/[id]/route.ts new file mode 100644 index 0000000000..62405ab687 --- /dev/null +++ b/app/api/agent-ops/triggers/[id]/route.ts @@ -0,0 +1,29 @@ +import { deleteTriggerById, patchTrigger, triggerResponse, unexpectedErrorResponse } from "@/lib/agent-ops/trigger-api"; + +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ id: string }> }; + +// PATCH /api/agent-ops/triggers/[id] body: any of name, profile, promptTemplate, enabled, everyMinutes (null clears), dedupWindowMs, maxActiveTasks +export async function PATCH(req: Request, { params }: Context) { + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + try { + return triggerResponse(patchTrigger((await params).id, body)); + } catch (error) { + return unexpectedErrorResponse(error); + } +} + +// DELETE /api/agent-ops/triggers/[id] - Its fire tokens are purged by the scheduler after 24 h. +export async function DELETE(_req: Request, { params }: Context) { + try { + return triggerResponse(deleteTriggerById((await params).id)); + } catch (error) { + return unexpectedErrorResponse(error); + } +} diff --git a/app/api/agent-ops/triggers/[id]/secret/route.ts b/app/api/agent-ops/triggers/[id]/secret/route.ts new file mode 100644 index 0000000000..9e1a1ca720 --- /dev/null +++ b/app/api/agent-ops/triggers/[id]/secret/route.ts @@ -0,0 +1,12 @@ +import { rotateSecret, triggerResponse, unexpectedErrorResponse } from "@/lib/agent-ops/trigger-api"; + +export const dynamic = "force-dynamic"; + +// POST /api/agent-ops/triggers/[id]/secret - Generate a new webhook secret (the old one stops working) and return it once. +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + try { + return triggerResponse(rotateSecret((await params).id)); + } catch (error) { + return unexpectedErrorResponse(error); + } +} diff --git a/app/api/agent-ops/triggers/route.ts b/app/api/agent-ops/triggers/route.ts new file mode 100644 index 0000000000..c7dcd82d15 --- /dev/null +++ b/app/api/agent-ops/triggers/route.ts @@ -0,0 +1,30 @@ +import { createTriggerFromInput, listPublicTriggers, triggerResponse, unexpectedErrorResponse } from "@/lib/agent-ops/trigger-api"; + +export const dynamic = "force-dynamic"; + +// GET /api/agent-ops/triggers[?agent=] - All triggers, or the ones of one agent; secrets are never listed (hasWebhookSecret only). +export async function GET(req: Request) { + try { + const agent = new URL(req.url).searchParams.get("agent"); + return Response.json({ triggers: listPublicTriggers().filter((trigger) => !agent || trigger.profile === agent) }, { headers: { "Cache-Control": "no-store" } }); + } catch (error) { + return unexpectedErrorResponse(error); + } +} + +// POST /api/agent-ops/triggers body: { name, profile (the agent), promptTemplate, everyMinutes?, webhook?, dedupWindowMs?, maxActiveTasks?, enabled? } +// Creates a trigger pinned to the profile's current content. With `webhook: true` the response carries the generated secret, once. +export async function POST(req: Request) { + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + try { + const result = createTriggerFromInput(body); + return triggerResponse(result, 201); + } catch (error) { + return unexpectedErrorResponse(error); + } +} diff --git a/app/api/agent/[id]/route.ts b/app/api/agent/[id]/route.ts index a573dfef00..c53ccbb134 100644 --- a/app/api/agent/[id]/route.ts +++ b/app/api/agent/[id]/route.ts @@ -1,7 +1,17 @@ import { NextResponse } from "next/server"; import { resolveSessionPath } from "@/lib/session-reader"; +import { readSessionAgentProfileInfo } from "@/lib/subagents"; +import { getSessionEntries } from "@/lib/session-reader"; import { startRpcSession, getRpcSession, setRpcSessionTools } from "@/lib/rpc-manager"; +/** Trust comes from the session itself: the open wrapper's entries, else the file's. Unreadable means not isolated-provable, so it stays an ordinary session. */ +async function isIsolatedRun(id: string, existing: ReturnType): Promise { + if (existing?.isAlive()) return existing.agentProfileInfo()?.trust === "untrusted"; + const filePath = await resolveSessionPath(id); + if (!filePath) return false; + return readSessionAgentProfileInfo(getSessionEntries(filePath))?.trust === "untrusted"; +} + // POST /api/agent/[id] - Send a command to an existing session export async function POST( req: Request, @@ -25,6 +35,10 @@ export async function POST( // Fast path: already-running session const existing = getRpcSession(id); + // An isolated run is for reading: only the get_ queries pass. The runner sends through the wrapper, not this route. + if (!(typeof body.type === "string" && body.type.startsWith("get_")) && await isIsolatedRun(id, existing)) { + return NextResponse.json({ error: "isolated run is read-only" }, { status: 403 }); + } if (body.type === "set_tools") { const filePath = existing?.sessionFile || await resolveSessionPath(id) || undefined; if (!existing?.isAlive() && !filePath) { diff --git a/app/api/agent/new/route.ts b/app/api/agent/new/route.ts index 0a9184adce..0b11d658a1 100644 --- a/app/api/agent/new/route.ts +++ b/app/api/agent/new/route.ts @@ -45,7 +45,10 @@ export async function POST(req: Request) { } // Use a one-time key so startRpcSession's lock doesn't conflict with real session ids - const { provider, modelId, toolNames, thinkingLevel, ...promptCommand } = command as { provider?: string; modelId?: string; toolNames?: string[]; thinkingLevel?: unknown; [key: string]: unknown }; + const { provider, modelId, toolNames, thinkingLevel, agentProfile, ...promptCommand } = command as { provider?: string; modelId?: string; toolNames?: string[]; thinkingLevel?: unknown; agentProfile?: unknown; [key: string]: unknown }; + if (agentProfile !== undefined && (typeof agentProfile !== "string" || !agentProfile.trim())) { + throw new Error("agentProfile must be a non-empty string"); + } if ((provider && !modelId) || (!provider && modelId)) { throw new Error("provider and modelId must be provided together"); } @@ -56,7 +59,8 @@ export async function POST(req: Request) { // requests in the same millisecond, merging two new sessions into one. const tempKey = `__new__${randomUUID()}`; const { session, realSessionId } = await startRpcSession(tempKey, "", cwd, { - ...(toolNames ? { toolNames } : {}), + // A profile fixes its own tools, so a tool preset sent alongside it is ignored. + ...(agentProfile ? { agentProfile } : toolNames ? { toolNames } : {}), ...(provider && modelId ? { initialModel: { provider, modelId } } : {}), ...(explicitThinkingLevel ? { thinkingLevel: explicitThinkingLevel } : {}), }); diff --git a/app/api/agents/[name]/memory/forget/route.ts b/app/api/agents/[name]/memory/forget/route.ts new file mode 100644 index 0000000000..75e1b40806 --- /dev/null +++ b/app/api/agents/[name]/memory/forget/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { getLongTermAgent } from "@/lib/agents/registry"; +import { MemoryNotFoundError, requestForget } from "@/lib/agents/memory"; +import { withThreadLock } from "@/lib/agents/thread"; + +export const dynamic = "force-dynamic"; +// POST /api/agents/[name]/memory/forget body: { memoryId } - queue a forget request for pi-mem0's watcher. +export async function POST(req: Request, { params }: { params: Promise<{ name: string }> }) { + const agent = getLongTermAgent((await params).name); + if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 }); + let body: { memoryId?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } + if (typeof body.memoryId !== "string") return NextResponse.json({ error: "memoryId is required" }, { status: 400 }); + const memoryId = body.memoryId; + try { + const requestId = await withThreadLock(agent.name, async () => requestForget(agent.name, memoryId)); + return NextResponse.json({ requestId }, { status: 202 }); + } catch (error) { + if (error instanceof MemoryNotFoundError) return NextResponse.json({ error: error.message }, { status: 404 }); + return NextResponse.json({ error: error instanceof Error ? error.message : "Invalid request" }, { status: 400 }); + } +} diff --git a/app/api/agents/[name]/memory/route.ts b/app/api/agents/[name]/memory/route.ts new file mode 100644 index 0000000000..c7cfa9dc26 --- /dev/null +++ b/app/api/agents/[name]/memory/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { listStagedFacts } from "@/lib/agent-ops/memory-review"; +import { getLongTermAgent } from "@/lib/agents/registry"; +import { listPendingForgets, readAgentMemorySnapshot } from "@/lib/agents/memory"; + +export const dynamic = "force-dynamic"; +// GET /api/agents/[name]/memory - recent snapshot, pending forget requests, staged approval queue. +export async function GET(_req: Request, { params }: { params: Promise<{ name: string }> }) { + const agent = getLongTermAgent((await params).name); + if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 }); + return NextResponse.json({ + recent: readAgentMemorySnapshot(agent.name), + pendingForget: listPendingForgets(agent.name), + staged: listStagedFacts().filter((fact) => fact.agent === agent.name), + }, { headers: { "Cache-Control": "no-store" } }); +} diff --git a/app/api/agents/[name]/read/route.ts b/app/api/agents/[name]/read/route.ts new file mode 100644 index 0000000000..463e3cd213 --- /dev/null +++ b/app/api/agents/[name]/read/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { AgentRegistryError, getLongTermAgent, setLastReadEntryId } from "@/lib/agents/registry"; + +export const dynamic = "force-dynamic"; +const ENTRY_ID = /^[A-Za-z0-9_-]{1,64}$/; +// POST /api/agents/[name]/read body: { entryId } - the thread was displayed down to this entry. +export async function POST(req: Request, { params }: { params: Promise<{ name: string }> }) { + const agent = getLongTermAgent((await params).name); + if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 }); + let body: { entryId?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } + if (typeof body.entryId !== "string" || !ENTRY_ID.test(body.entryId)) return NextResponse.json({ error: "entryId is required" }, { status: 400 }); + try { setLastReadEntryId(agent.name, body.entryId); } catch (error) { + if (error instanceof AgentRegistryError) return NextResponse.json({ error: error.message }, { status: 404 }); + throw error; + } + return NextResponse.json({ ok: true }); +} diff --git a/app/api/agents/[name]/route.ts b/app/api/agents/[name]/route.ts new file mode 100644 index 0000000000..9c1af09a73 --- /dev/null +++ b/app/api/agents/[name]/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server"; +import { cancelQueuedTasksOfAgent } from "@/lib/agent-ops/task-store"; +import { deleteTriggersOfAgent, repinTriggersOfAgent } from "@/lib/agent-ops/trigger-api"; +import { canEditProfile, splitModel, toAgentDetail } from "@/lib/agents/agent-view"; +import { deleteLongTermAgent, getLongTermAgent, updateLongTermAgent, validateUpdateInput } from "@/lib/agents/registry"; +import { openThread, threadRunning, unreadCount, withThreadLock } from "@/lib/agents/thread"; +import { getRpcSession, isRpcSessionStarting } from "@/lib/rpc-manager"; +import { invalidateSessionListCache, invalidateSessionPathCache, resolveSessionPath } from "@/lib/session-reader"; +import { registryErrorResponse } from "@/lib/agents/registry-response"; + +export const dynamic = "force-dynamic"; +const headers = { "Cache-Control": "no-store" }; +type Context = { params: Promise<{ name: string }> }; +const notFound = () => NextResponse.json({ error: "Agent not found" }, { status: 404, headers }); + +export async function GET(_req: Request, { params }: Context) { + const agent = getLongTermAgent((await params).name); + if (!agent) return notFound(); + return NextResponse.json({ agent: toAgentDetail(agent, threadRunning(agent), await unreadCount(agent)) }, { headers }); +} + +// PATCH: role and tools apply at the next open (the trusted thread re-snapshots, lib/rpc-manager.ts); model and +// thinking are sent to the thread so the file records them; avatar only touches the space state. +export async function PATCH(req: Request, { params }: Context) { + const { name } = await params; + if (!getLongTermAgent(name)) return notFound(); + let body: unknown; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400, headers }); } + const checked = validateUpdateInput(body); + if (!checked.ok) return NextResponse.json({ error: checked.error }, { status: 400, headers }); + const agent = getLongTermAgent(name); // re-read after the body parse: the agent may have changed meanwhile + if (!agent) return notFound(); + const gate = canEditProfile(threadRunning(agent)); + if (!gate.ok) return NextResponse.json({ error: gate.error }, { status: gate.status, headers }); + try { + const updated = updateLongTermAgent(agent.name, checked.input); + repinTriggersOfAgent(agent.name); // a saved Profile settings edit is the authenticated drift the pin exists to catch + if (agent.threadSessionId) { + const modelChanged = "model" in checked.input && updated.model !== agent.model; + const thinkingChanged = "thinking" in checked.input && updated.thinking !== agent.thinking; + if ((modelChanged && updated.model) || (thinkingChanged && updated.thinking)) { + const { session } = await openThread(updated); + const model = updated.model ? splitModel(updated.model) : null; + if (modelChanged && model) await session.send({ type: "set_model", provider: model.provider, modelId: model.modelId }); + if (thinkingChanged && updated.thinking) await session.send({ type: "set_thinking_level", level: updated.thinking }); + } + // The adapter re-reads its config only when the session restarts. + if ("role" in checked.input || "toolsPreset" in checked.input || "mcpServers" in checked.input) getRpcSession(agent.threadSessionId)?.shutdownWhenIdle(); + } + return NextResponse.json({ agent: toAgentDetail(updated, threadRunning(updated), await unreadCount(updated)) }, { headers }); + } catch (error) { return registryErrorResponse(error); } +} + +// DELETE: profile and space removed, home and thread moved to the trash (reversible by hand). +export async function DELETE(_req: Request, { params }: Context) { + const { name } = await params; + if (!getLongTermAgent(name)) return notFound(); + try { + return await withThreadLock(name, async () => { + const agent = getLongTermAgent(name); // re-read under the lock: a thread may have been created while we waited + if (!agent) return notFound(); + const id = agent.threadSessionId; + const busy = () => Boolean(id && (isRpcSessionStarting(id) || getRpcSession(id)?.isAlive())); + const running = () => NextResponse.json({ error: "agent_running" }, { status: 409, headers }); + if (id && isRpcSessionStarting(id)) return running(); + const live = id ? getRpcSession(id) : undefined; + if (live?.isAlive()) { + if (live.isRunning()) return running(); + await live.shutdown(); // idle wrapper kept by the idle release: close it, then delete + } + if (busy()) return running(); // it came back meanwhile + const threadPath = id ? await resolveSessionPath(id) : null; + if (busy()) return running(); // started during the await + cancelQueuedTasksOfAgent(agent.name); // a queued task must not fire into a vanished agent + deleteTriggersOfAgent(agent.name); // no trigger may outlive its agent; the agent is idle, nothing fires into the trash + const trash = deleteLongTermAgent(agent.name, threadPath ?? undefined); + if (agent.threadSessionId) invalidateSessionPathCache(agent.threadSessionId); + invalidateSessionListCache(); + return NextResponse.json({ trash }, { headers }); + }); + } catch (error) { return registryErrorResponse(error); } +} diff --git a/app/api/agents/[name]/tasks/route.ts b/app/api/agents/[name]/tasks/route.ts new file mode 100644 index 0000000000..402151b6ba --- /dev/null +++ b/app/api/agents/[name]/tasks/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { kickRunner, recoverOnce } from "@/lib/agent-ops/kick"; +import { shapeTaskList } from "@/lib/agent-ops/task-list"; +import { createTask, listTasks } from "@/lib/agent-ops/task-store"; +import { getLongTermAgent } from "@/lib/agents/registry"; +import { invalidateSessionListCache } from "@/lib/session-reader"; + +export const dynamic = "force-dynamic"; + +const TITLE_MAX = 80; +const PROMPT_MAX = 20_000; + +// GET /api/agents/[name]/tasks - this agent's tasks, shaped like /api/agent-ops/tasks. +export async function GET(_req: Request, { params }: { params: Promise<{ name: string }> }) { + recoverOnce(); + const name = (await params).name; + if (!getLongTermAgent(name)) return NextResponse.json({ error: "Agent not found" }, { status: 404 }); + return NextResponse.json(shapeTaskList(listTasks().filter((task) => task.agent === name)), { headers: { "Cache-Control": "no-store" } }); +} + +// POST /api/agents/[name]/tasks body: { prompt } - Queue a task that runs in the agent's thread when it is idle. +export async function POST(req: Request, { params }: { params: Promise<{ name: string }> }) { + recoverOnce(); + const agent = getLongTermAgent((await params).name); + if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 }); + let body: { prompt?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const { prompt } = body; + if (typeof prompt !== "string" || !prompt.trim()) return NextResponse.json({ error: "prompt is required" }, { status: 400 }); + if (prompt.length > PROMPT_MAX) return NextResponse.json({ error: `prompt is limited to ${PROMPT_MAX} characters` }, { status: 400 }); + + const task = createTask({ + agent: agent.name, target: "thread", kind: "task", profile: agent.name, cwd: agent.home, prompt, + title: prompt.trim().split("\n")[0].slice(0, TITLE_MAX), origin: "ui", + }); + invalidateSessionListCache(); + void kickRunner(); + return NextResponse.json({ task }, { status: 201 }); +} diff --git a/app/api/agents/[name]/thread/reset/route.ts b/app/api/agents/[name]/thread/reset/route.ts new file mode 100644 index 0000000000..24ece36c56 --- /dev/null +++ b/app/api/agents/[name]/thread/reset/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { archiveThread, getLongTermAgent } from "@/lib/agents/registry"; +import { registryErrorResponse } from "@/lib/agents/registry-response"; +import { ensureThreadLocked, withThreadLock } from "@/lib/agents/thread"; +import { getRpcSession, isRpcSessionStarting } from "@/lib/rpc-manager"; +import { invalidateSessionListCache, invalidateSessionPathCache, resolveSessionPath } from "@/lib/session-reader"; + +export const dynamic = "force-dynamic"; +const headers = { "Cache-Control": "no-store" }; +type Context = { params: Promise<{ name: string }> }; +const notFound = () => NextResponse.json({ error: "Agent not found" }, { status: 404, headers }); + +// POST: archive the thread (trash, reversible by hand) and start a fresh one under the same lock. Queued tasks and +// triggers belong to the agent, not the thread: they stay and run in whatever thread opens next. +export async function POST(_req: Request, { params }: Context) { + const { name } = await params; + if (!getLongTermAgent(name)) return notFound(); + try { + return await withThreadLock(name, async () => { + const agent = getLongTermAgent(name); // re-read under the lock: a thread may have been created while we waited + if (!agent) return notFound(); + const id = agent.threadSessionId; + const busy = () => Boolean(id && (isRpcSessionStarting(id) || getRpcSession(id)?.isAlive())); + const running = () => NextResponse.json({ error: "agent_running" }, { status: 409, headers }); + if (id && isRpcSessionStarting(id)) return running(); + const live = id ? getRpcSession(id) : undefined; + if (live?.isAlive()) { + if (live.isRunning()) return running(); + await live.shutdown(); // idle wrapper kept by the idle release: close it, then archive + } + if (busy()) return running(); // it came back meanwhile + const threadPath = id ? await resolveSessionPath(id) : null; + if (busy()) return running(); // started during the await + const trash = archiveThread(agent.name, threadPath ?? undefined); + if (id) invalidateSessionPathCache(id); + invalidateSessionListCache(); + const { sessionId } = await ensureThreadLocked(agent); + return NextResponse.json({ sessionId, trash }, { headers }); + }); + } catch (error) { return registryErrorResponse(error); } +} diff --git a/app/api/agents/[name]/thread/route.ts b/app/api/agents/[name]/thread/route.ts new file mode 100644 index 0000000000..9800c36bdb --- /dev/null +++ b/app/api/agents/[name]/thread/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getLongTermAgent } from "@/lib/agents/registry"; +import { ensureThread } from "@/lib/agents/thread"; + +export const dynamic = "force-dynamic"; +// POST /api/agents/[name]/thread - the pinned session id, created on the first open. +export async function POST(_req: Request, { params }: { params: Promise<{ name: string }> }) { + const agent = getLongTermAgent((await params).name); + if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 }); + try { + const { sessionId } = await ensureThread(agent); + return NextResponse.json({ sessionId, lastReadEntryId: agent.lastReadEntryId ?? null }, { headers: { "Cache-Control": "no-store" } }); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + } +} diff --git a/app/api/agents/mcp-servers/route.ts b/app/api/agents/mcp-servers/route.ts new file mode 100644 index 0000000000..cba4f0a08c --- /dev/null +++ b/app/api/agents/mcp-servers/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { listGlobalMcpServers } from "@/lib/agents/mcp-access"; + +export const dynamic = "force-dynamic"; + +// GET /api/agents/mcp-servers - names of the pi-mcp-adapter servers an agent can be allowed (never their config). +export async function GET() { + return NextResponse.json({ servers: listGlobalMcpServers() }, { headers: { "Cache-Control": "no-store" } }); +} diff --git a/app/api/agents/route.test.mjs b/app/api/agents/route.test.mjs new file mode 100644 index 0000000000..5e5a9b6277 --- /dev/null +++ b/app/api/agents/route.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +const read = (path) => readFile(new URL(path, import.meta.url), "utf8"); + +test("the agent routes validate through the registry and never answer with the role in lists", async () => { + const list = await read("./route.ts"); + assert.match(list, /validateCreateInput\(/); + assert.match(list, /toAgentListItem\(/); + assert.match(list, /allowFileRoot\(/); + assert.match(list, /agentsHomeDir: agentsHomeDir\(\)/); + assert.match(list, /"Cache-Control": "no-store"/); +}); +test("PATCH and DELETE refuse while the thread runs, and profile edits reach the live thread", async () => { + const one = await read("./[name]/route.ts"); + assert.match(one, /canEditProfile\(threadRunning\(/); + assert.match(one, /type: "set_model"/); + assert.match(one, /type: "set_thinking_level"/); + assert.match(one, /shutdownWhenIdle\(\)/); + assert.match(one, /deleteLongTermAgent\(/); + assert.match(one, /withThreadLock\(name/); + assert.match(one, /isRpcSessionStarting\(id\)/); + assert.match(one, /re-read under the lock/); + assert.match(one, /await live\.shutdown\(\)/); + assert.match(one, /if \(busy\(\)\) return running\(\); \/\/ it came back meanwhile/); + assert.match(one, /invalidateSessionPathCache\(/); +}); +test("the sessions list leaves agent homes out, and long-term agents are never delegable", async () => { + assert.match(await read("../sessions/route.ts"), /isAgentHomePath\(/); + assert.match(await read("../subagents/profiles/route.ts"), /filter\(\(profile\) => !profile\.longTerm\)/); + assert.match(await read("../../../lib/subagent-runtime.ts"), /if \(profile\.longTerm\) throw new Error/); + assert.match(await read("../../../lib/rpc-manager.ts"), /listSubagentProfiles\(sessionCwd\)\.filter\(\(profile\) => !profile\.longTerm\)/); +}); +test("a saved profile re-pins the agent's triggers and a delete removes them before the registry delete", async () => { + const one = await read("./[name]/route.ts"); + assert.match(one, /updateLongTermAgent\([^\n]*\);\s*repinTriggersOfAgent\(agent\.name\)/); + assert.match(one, /cancelQueuedTasksOfAgent\(agent\.name\);[^\n]*\n\s*deleteTriggersOfAgent\(agent\.name\)/); + assert.match(one, /deleteTriggersOfAgent\(agent\.name\);[^\n]*\n\s*const trash = deleteLongTermAgent\(/); + const triggers = await read("../agent-ops/triggers/route.ts"); + assert.match(triggers, /searchParams\.get\("agent"\)/); + assert.match(triggers, /trigger\.profile === agent/); +}); + +test("an isolated (untrusted agent-profile) run refuses every command but the get_ queries", async () => { + const route = await read("../agent/[id]/route.ts"); + assert.match(route, /readSessionAgentProfileInfo\(/); + assert.match(route, /trust === "untrusted"/); + assert.match(route, /isolated run is read-only/); + assert.match(route, /status: 403/); + assert.match(route, /startsWith\("get_"\)/); + assert.ok(route.indexOf("isolated run is read-only") < route.indexOf("existing.send(body)")); + assert.ok(route.indexOf("isolated run is read-only") < route.indexOf('body.type === "set_tools"')); +}); + +test("the session detail route puts the agent profile trust on info so a run opened by id renders read-only", async () => { + const route = await read("../sessions/[id]/route.ts"); + assert.match(route, /readSessionAgentProfileInfo\(entries as never\)/); + assert.match(route, /agentProfile: agentProfileInfo/); +}); + +test("POST /api/agents/[name]/thread/reset archives under the lock and starts the new thread, keeping tasks and triggers", async () => { + const reset = await read("./[name]/thread/reset/route.ts"); + assert.match(reset, /withThreadLock\(name/); + assert.match(reset, /agent_running/); + assert.match(reset, /archiveThread\(/); + assert.match(reset, /ensureThreadLocked\(/); + assert.match(reset, /registryErrorResponse/); + assert.doesNotMatch(reset, /cancelQueuedTasksOfAgent|deleteTriggersOfAgent/); +}); +test("MCP allowlist: PATCH restarts the thread, the route lists names only, spawn syncs before an isolated run, dialogs show checkboxes", async () => { + assert.match(await read("./[name]/route.ts"), /"mcpServers" in checked\.input/); + const route = await read("./mcp-servers/route.ts"); + assert.match(route, /listGlobalMcpServers\(\)/); + assert.match(route, /"Cache-Control": "no-store"/); + assert.match(route, /dynamic = "force-dynamic"/); + assert.match(await read("../../../lib/agent-ops/spawn.ts"), /syncAgentMcpOverrides\(agent\.home, agent\.mcpServers\)/); + for (const dialog of ["NewAgentDialog", "AgentProfileDialog"]) { + const source = await read(`../../../components/agents/${dialog}.tsx`); + assert.match(source, /\/api\/agents\/mcp-servers/); + assert.match(source, /type="checkbox"/); + } + assert.match(await read("../../../lib/agents/agent-view.ts"), /mcpServers: agent\.mcpServers/); +}); diff --git a/app/api/agents/route.ts b/app/api/agents/route.ts new file mode 100644 index 0000000000..f0d9e32787 --- /dev/null +++ b/app/api/agents/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { allowFileRoot } from "@/lib/file-access"; +import { toAgentDetail, toAgentListItem } from "@/lib/agents/agent-view"; +import { agentsHomeDir, createLongTermAgent, listLongTermAgents, validateCreateInput } from "@/lib/agents/registry"; +import { registryErrorResponse } from "@/lib/agents/registry-response"; +import { threadRunning, unreadCount } from "@/lib/agents/thread"; + +export const dynamic = "force-dynamic"; +const headers = { "Cache-Control": "no-store" }; + +// GET /api/agents - the rail: every long-term agent with running state and unread count. +export async function GET() { + const agents = await Promise.all(listLongTermAgents().map(async (agent) => toAgentListItem(agent, threadRunning(agent), await unreadCount(agent).catch(() => 0)))); + return NextResponse.json({ agents, agentsHomeDir: agentsHomeDir() }, { headers }); +} + +// POST /api/agents body: CreateAgentInput - create the profile, space state and home. +export async function POST(req: Request) { + let body: unknown; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400, headers }); } + const checked = validateCreateInput(body); + if (!checked.ok) return NextResponse.json({ error: checked.error }, { status: 400, headers }); + try { + const agent = createLongTermAgent(checked.input); + allowFileRoot(agent.home); // the home is browsable in the left panel before any session exists + return NextResponse.json({ agent: toAgentDetail(agent, false, 0) }, { status: 201, headers }); + } catch (error) { return registryErrorResponse(error); } +} diff --git a/app/api/models/default/route.ts b/app/api/models/default/route.ts index 75de97bd18..3957c4f472 100644 --- a/app/api/models/default/route.ts +++ b/app/api/models/default/route.ts @@ -10,6 +10,7 @@ import { } from "@/lib/default-preferences"; import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; import { resolveVisibleModels } from "@/lib/model-scope"; +import { withDeferredProviderModels } from "@/lib/deferred-provider-models"; import { invalidateModelsCache } from "@/lib/models-cache"; import { projectTrustReloadOptions } from "@/lib/project-trust"; @@ -95,7 +96,7 @@ export async function PUT(req: Request) { if (edit.model) { // Only a model the selector can offer is a default that actually takes // effect: startup falls back to the first scoped model otherwise. - const scope = await resolveVisibleModels(services.modelRuntime, settingsManager.getEnabledModels()); + const scope = await resolveVisibleModels(withDeferredProviderModels(services.modelRuntime), settingsManager.getEnabledModels()); const { provider, modelId } = edit.model; if (!scope.visible.some((model) => model.provider === provider && model.id === modelId)) { return Response.json({ error: `Model not available: ${provider}/${modelId}` }, { status: 404 }); diff --git a/app/api/models/enabled/route.ts b/app/api/models/enabled/route.ts index aedf4e5434..b5060233d8 100644 --- a/app/api/models/enabled/route.ts +++ b/app/api/models/enabled/route.ts @@ -23,6 +23,7 @@ import { } from "@/lib/enabled-models-runtime"; import type { EnabledModelsInput } from "@/lib/enabled-models"; import { createModelRuntimeWithExtensions } from "@/lib/model-runtime"; +import { withDeferredProviderModels } from "@/lib/deferred-provider-models"; import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; import { invalidateModelsCache } from "@/lib/models-cache"; @@ -37,7 +38,7 @@ interface RequestContext { } async function loadContext(cwd: string): Promise { - const modelRuntime = await createModelRuntimeWithExtensions(); + const modelRuntime = withDeferredProviderModels(await createModelRuntimeWithExtensions()); const agentDir = getAgentDir(); return { modelRuntime, diff --git a/app/api/models/route.ts b/app/api/models/route.ts index 677bc25dfc..fffc14cb7b 100644 --- a/app/api/models/route.ts +++ b/app/api/models/route.ts @@ -11,6 +11,7 @@ import { import { resolveVisibleModels, selectInitialModelScope } from "@/lib/model-scope"; import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; import { projectTrustReloadOptions } from "@/lib/project-trust"; +import { rememberProviderModels, withDeferredProviderModels } from "@/lib/deferred-provider-models"; export const dynamic = "force-dynamic"; @@ -44,10 +45,11 @@ async function loadModels(cwd: string): Promise { }); const modelError = services.modelRuntime.getError(); const settings: SettingsManager = services.settingsManager; + await rememberProviderModels(services.modelRuntime); // `enabledModels` supports globs and fuzzy patterns, so resolve it the same // way the CLI does instead of comparing pattern strings literally (#307). const scope = await resolveVisibleModels( - services.modelRuntime, + withDeferredProviderModels(services.modelRuntime), settings.getEnabledModels(), ); const { visible, thinkingLevelPins, warnings } = scope; diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index d75f3b31ed..7e9259f23e 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -22,7 +22,7 @@ import { computeSessionStats } from "@/lib/session-stats"; import { startServerPerf } from "@/lib/perf"; import { computeSessionRevision } from "@/lib/session-revision"; import type { SessionEntry } from "@/lib/types"; -import { readSubagentRun, readSubagentSessionResources, SUBAGENT_META_TYPE } from "@/lib/subagents"; +import { readSessionAgentProfile, readSessionAgentProfileInfo, readSubagentRun, readSubagentSessionResources, SUBAGENT_META_TYPE } from "@/lib/subagents"; import { readSessionToolSelection } from "@/lib/session-tool-selection"; import { jsonResponse } from "@/lib/json-response"; @@ -108,6 +108,8 @@ export async function GET( : null; const toolNames = readSubagentSessionResources(entries as never)?.tools ?? readSessionToolSelection(entries as never); + const agentProfile = readSessionAgentProfile(entries as never); + const agentProfileInfo = readSessionAgentProfileInfo(entries as never); const info = header ? (await attachSessionProjectInfo([{ path: filePath, id: header.id, @@ -128,6 +130,7 @@ export async function GET( : header.parentSession ? { relation: { kind: "fork" as const, ...(parentSessionId ? { originSessionId: parentSessionId } : {}) } } : {}), + ...(agentProfileInfo ? { agentProfile: agentProfileInfo } : {}), transient: !filePath || !existsSync(filePath), }]))[0] : null; @@ -145,6 +148,7 @@ export async function GET( stats, totalActiveMs, ...(toolNames !== undefined ? { toolNames } : {}), + ...(agentProfile ? { agentProfile } : {}), ...(wrapperRebuilt ? { wrapperRebuilt: true } : {}), }, )) ?? jsonResponse( @@ -161,6 +165,7 @@ export async function GET( stats, totalActiveMs, ...(toolNames !== undefined ? { toolNames } : {}), + ...(agentProfile ? { agentProfile } : {}), ...(wrapperRebuilt ? { wrapperRebuilt: true } : {}), }, ); diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index 2b12e7f8b8..300b7c8a4e 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -12,6 +12,7 @@ import { getRpcSessionInfos, getRunningRpcSessionIds, } from "@/lib/rpc-manager"; +import { isAgentHomePath } from "@/lib/agents/registry"; import { startServerPerf } from "@/lib/perf"; export const dynamic = "force-dynamic"; @@ -35,7 +36,7 @@ export async function GET(req: Request) { attachSessionProjectInfo(getRpcSessionInfos()), ]); perf?.span("scan+projects"); - const sessions = mergeSessionLists(persistedSessions, runtimeSessions); + const sessions = mergeSessionLists(persistedSessions, runtimeSessions).filter((session) => !isAgentHomePath(session.cwd)); return perf?.attach(jsonResponse( req, { diff --git a/app/api/subagents/profiles/route.test.mjs b/app/api/subagents/profiles/route.test.mjs index 6d433c3294..36f72f64d6 100644 --- a/app/api/subagents/profiles/route.test.mjs +++ b/app/api/subagents/profiles/route.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test, { after } from "node:test"; @@ -225,3 +225,30 @@ test("profiles route rejects missing paths, malformed profiles, and unsafe names assert.equal(response.status, 400); assert.deepEqual(await response.json(), { error: "enabled required" }); }); + +test("a long-term agent's profile cannot be written, toggled or deleted from Settings", async (t) => { + const cwd = await mkdtemp(join(tmpdir(), "pi-web-subagent-route-lt-")); + allowFileRoot(cwd); + t.after(() => rm(cwd, { recursive: true, force: true })); + const file = join(testAgentDir, "agents", "leandro.md"); + await mkdir(join(testAgentDir, "agents"), { recursive: true }); + await writeFile(file, "---\ndescription: lt\nlong_term: true\n---\nrole\n"); + const before = await readFile(file, "utf8"); + for (const name of ["leandro", "Leandro"]) { + for (const response of [ + await PUT(jsonRequest("PUT", { cwd, scope: "global", profile: profile({ name }) })), + await PUT(jsonRequest("PUT", { cwd, scope: "project", profile: profile({ name }) })), + await PATCH(jsonRequest("PATCH", { cwd, scope: "global", name, enabled: false })), + await DELETE(jsonRequest("DELETE", { cwd, scope: "global", name })), + ]) { + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: "long-term agent" }); + } + } + assert.equal(await readFile(file, "utf8"), before); + assert.equal(existsSync(join(cwd, ".pi", "agents", "leandro.md")), false); + + const response = await PUT(jsonRequest("PUT", { cwd, scope: "project", profile: profile({ name: "ordinary", longTerm: true }) })); + assert.equal(response.status, 200); + assert.doesNotMatch(await readFile(join(cwd, ".pi", "agents", "ordinary.md"), "utf8"), /long_term/); +}); diff --git a/app/api/subagents/profiles/route.ts b/app/api/subagents/profiles/route.ts index c1959a2ecc..59176c1c5b 100644 --- a/app/api/subagents/profiles/route.ts +++ b/app/api/subagents/profiles/route.ts @@ -30,10 +30,16 @@ function validateToggleScope(scope: unknown): SubagentWritableScope | "builtin" return scope; } +/** The GET hides long-term agents, so the Settings UI cannot see a collision: refuse to touch their profile files. */ +function longTermConflict(cwd: string, name: string): NextResponse | null { + const taken = listSubagentProfileSources(cwd).some((profile) => profile.longTerm === true && profile.name.toLowerCase() === name.toLowerCase()); + return taken ? NextResponse.json({ error: "long-term agent" }, { status: 409 }) : null; +} + export async function GET(req: Request) { try { const cwd = await validateCwd(new URL(req.url).searchParams.get("cwd")); - return NextResponse.json({ profiles: listSubagentProfileSources(cwd) }); + return NextResponse.json({ profiles: listSubagentProfileSources(cwd).filter((profile) => !profile.longTerm) }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return NextResponse.json({ error: message }, { status: message === "Access denied" ? 403 : 400 }); @@ -52,7 +58,11 @@ export async function PUT(req: Request) { if (!body.profile || typeof body.profile.name !== "string") { return NextResponse.json({ error: "profile required" }, { status: 400 }); } - return NextResponse.json({ profile: saveSubagentProfile(cwd, scope, body.profile) }); + const conflict = longTermConflict(cwd, body.profile.name); + if (conflict) return conflict; + const profile = { ...body.profile } as Record; + delete profile.longTerm; // a body never mints a long-term profile + return NextResponse.json({ profile: saveSubagentProfile(cwd, scope, profile as unknown as Omit) }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return NextResponse.json({ error: message }, { status: message === "Access denied" ? 403 : 400 }); @@ -67,6 +77,8 @@ export async function PATCH(req: Request) { if (typeof body.name !== "string") return NextResponse.json({ error: "name required" }, { status: 400 }); if (typeof body.enabled !== "boolean") return NextResponse.json({ error: "enabled required" }, { status: 400 }); const name = body.name; + const conflict = longTermConflict(cwd, name); + if (conflict) return conflict; const source = listSubagentProfileSources(cwd).find((profile) => profile.scope === scope && profile.name.toLowerCase() === name.toLowerCase() ); @@ -104,6 +116,8 @@ export async function DELETE(req: Request) { const cwd = await validateCwd(body.cwd); const scope = validateScope(body.scope); if (typeof body.name !== "string") return NextResponse.json({ error: "name required" }, { status: 400 }); + const conflict = longTermConflict(cwd, body.name); + if (conflict) return conflict; deleteSubagentProfile(cwd, scope, body.name); return NextResponse.json({ ok: true }); } catch (error) { diff --git a/app/globals.css b/app/globals.css index 3399e0ae6d..aa5b610b73 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1866,8 +1866,14 @@ span.token.table { outline-offset: -3px; } +.agent-rail { width: 44px; display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 8px 0; background: var(--bg-panel); border-right: 1px solid var(--border); flex-shrink: 0; } +.agent-rail-horizontal { width: auto; flex-direction: row; padding: 6px 8px; border-right: none; border-bottom: 1px solid var(--border); overflow-x: auto; } +.agent-badge { position: absolute; top: -4px; right: -6px; background: #e5484d; color: #fff; border-radius: 8px; font-size: 9px; line-height: 14px; padding: 0 4px; font-weight: 700; } +.agent-running-dot { position: absolute; bottom: -2px; right: -2px; width: 9px; height: 9px; border-radius: 50%; background: #30a46c; border: 2px solid var(--bg-panel); } + /* Mobile sidebar: slide in/out as overlay */ @media (max-width: 640px) { + .agent-rail { width: auto; flex-direction: row; padding: 6px 8px; border-right: none; border-bottom: 1px solid var(--border); overflow-x: auto; } .chat-stats-center { display: none !important; } @@ -1958,3 +1964,17 @@ span.token.table { padding-bottom: 6px !important; } } + +.agent-unread-divider { text-align: center; font-size: 11px; color: #e5484d; border-top: 1px solid #e5484d; margin: 8px 0; padding-top: 2px; } +.agent-space-section { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-dim); margin: 10px 0 4px; } + +.agent-notify { border: 1px solid #f5a524; border-radius: 6px; padding: 6px 10px; margin: 6px 0; font-size: 13px; } +.agent-notify-sent { color: var(--text-dim); font-size: 11px; } +.agent-event { border-left: 3px solid #f5a524; background: rgba(245,165,36,.08); padding: 6px 10px; border-radius: 4px; margin-bottom: 12px; font-size: 12px; } +.agent-read-only { padding: 10px; text-align: center; font-size: 12px; color: var(--text-muted); border-top: 1px solid var(--border); } +.agent-event-webhook { border-left-color: #8e7cc3; background: rgba(142,124,195,.10); } +.agent-event-head { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; color: var(--text-muted); } +.agent-event-link { margin-left: auto; font-size: 11px; } +.agent-event-failed { color: #e5484d; } +.agent-event-summary { margin-top: 4px; color: var(--text); } +.agent-event-prompt summary { cursor: pointer; font-size: 11px; color: var(--text-dim); } diff --git a/components/AgentProfileSelector.tsx b/components/AgentProfileSelector.tsx new file mode 100644 index 0000000000..1db64d1b01 --- /dev/null +++ b/components/AgentProfileSelector.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useI18n } from "@/hooks/useI18n"; + +interface ProfileOption { + name: string; + displayName: string; + description: string; +} + +interface Props { + cwd?: string; + agentProfile: string | null; + /** Present only while the session can still pick its agent (a new, unsent session). */ + onChange?: (profile: string | null) => void; + disabled?: boolean; + isMobile?: boolean; + showLabel?: boolean; +} + +/** Enabled profiles, one per name: the API lists shadowed sources too, highest precedence last. */ +function uniqueEnabledProfiles(profiles: Array): ProfileOption[] { + const byName = new Map(); + for (const profile of profiles) byName.set(profile.name.toLowerCase(), profile); + return [...byName.values()] + .filter((profile) => profile.enabled !== false) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); +} + +export function AgentProfileSelector({ cwd, agentProfile, onChange, disabled, isMobile, showLabel = true }: Props) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [profiles, setProfiles] = useState(null); + const rootRef = useRef(null); + const editable = Boolean(onChange) && !disabled; + + useEffect(() => { + if (!open || !cwd) return; + const controller = new AbortController(); + void fetch(`/api/subagents/profiles?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store", signal: controller.signal }) + .then((response) => response.ok ? response.json() : { profiles: [] }) + .then((body: { profiles?: Array }) => setProfiles(uniqueEnabledProfiles(body.profiles ?? []))) + .catch(() => { if (!controller.signal.aborted) setProfiles([]); }); + return () => controller.abort(); + }, [open, cwd]); + + useEffect(() => { + if (!open) return; + const close = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, [open]); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!profiles || !needle) return profiles ?? []; + return profiles.filter((profile) => + profile.name.toLowerCase().includes(needle) + || profile.displayName.toLowerCase().includes(needle) + || profile.description.toLowerCase().includes(needle)); + }, [profiles, query]); + + // A session without a profile that can no longer pick one has nothing to show. + if (!agentProfile && !editable) return null; + + const label = agentProfile ?? t("chat.defaultAgent"); + const select = (profile: string | null) => { + setOpen(false); + setQuery(""); + if (profile !== agentProfile) onChange?.(profile); + }; + const rowStyle = (active: boolean) => ({ + display: "flex", flexDirection: "column" as const, alignItems: "flex-start", gap: 2, + width: "100%", padding: "7px 12px", + background: active ? "var(--bg-selected)" : "none", + border: "none", + color: active ? "var(--text)" : "var(--text-muted)", + cursor: "pointer", fontSize: 12, textAlign: "left" as const, + fontWeight: active ? 600 : 400, + }); + + return ( +
+ + {open && ( +
+ setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { event.stopPropagation(); setOpen(false); } + if (event.key === "Enter" && filtered.length > 0) select(filtered[0].name); + }} + placeholder={t("chat.searchAgents")} + aria-label={t("chat.searchAgents")} + style={{ + margin: 8, padding: "6px 8px", fontSize: 12, + background: "var(--bg-panel)", color: "var(--text)", + border: "1px solid var(--border)", borderRadius: 6, outline: "none", + }} + /> +
+ {!query.trim() && ( + + )} + {filtered.map((profile) => ( + + ))} + {profiles && filtered.length === 0 && ( +
{t("chat.noAgentsFound")}
+ )} +
+
+ )} +
+ ); +} diff --git a/components/AppShell.open-agent.test.mjs b/components/AppShell.open-agent.test.mjs new file mode 100644 index 0000000000..4143df6f6d --- /dev/null +++ b/components/AppShell.open-agent.test.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; + +const source = fs.readFileSync(new URL("./AppShell.tsx", import.meta.url), "utf8"); + +test("openAgent shows agents.error when the thread cannot be opened", () => { + const body = source.slice(source.indexOf("const openAgent = useCallback"), source.indexOf("agentMountOpenedRef.current =")); + assert.match(body, /window\.alert\(translate\("agents\.error", \{ error:/); +}); diff --git a/components/AppShell.tsx b/components/AppShell.tsx index c50b18f66c..9049068ff3 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -10,6 +10,12 @@ import { FileViewer } from "./FileViewer"; import { TabBar, type Tab } from "./TabBar"; import { openFileTab, saveFileViewerState } from "./file-tab-state"; import { SettingsPanel, SettingsSectionIcon } from "./SettingsPanel"; +import { AgentRail, useAgentsPoll } from "./agents/AgentRail"; +import { NewAgentDialog } from "./agents/NewAgentDialog"; +import { AgentAvatar } from "./agents/AgentAvatar"; +import { AgentSpaceLeft } from "./agents/AgentSpaceLeft"; +import { AgentSpaceRight } from "./agents/AgentSpaceRight"; +import type { AgentDetail } from "@/lib/agents/agent-view"; import { ProjectTrustDialog, type ProjectTrustFailure } from "./ProjectTrustDialog"; import { BranchNavigator, hasSessionBranches } from "./BranchNavigator"; import { SystemPromptPanel } from "./SystemPromptPanel"; @@ -172,6 +178,13 @@ export function AppShell() { }, []); const [explorerRefreshKey, setExplorerRefreshKey] = useState(0); const [settingsSection, setSettingsSection] = useState(null); + const [activeAgent, setActiveAgent] = useState(initialNavigation.agentName); + const [agentDetail, setAgentDetail] = useState(null); + const [agentUnreadMarker, setAgentUnreadMarker] = useState(null); + const [newAgentOpen, setNewAgentOpen] = useState(false); + const pendingAgentRef = useRef<{ sessionId: string; agentName: string } | null>(null); + const agentMountOpenedRef = useRef(false); + const { agents, agentsHomeDir, reload: reloadAgents } = useAgentsPoll(); const [modelsRefreshKey, setModelsRefreshKey] = useState(0); const [projectTrust, setProjectTrust] = useState(null); const [projectTrustDialogOpen, setProjectTrustDialogOpen] = useState(false); @@ -563,7 +576,7 @@ export function AppShell() { ?? activeProjectKeyRef.current ?? workspaceKeyOf(selectedSession); setLastOpenSession(projectKey, selectedSession.id); - setTabOpenSession(selectedSession.id); + if (pendingAgentRef.current?.sessionId !== selectedSession.id) setTabOpenSession(selectedSession.id); // an agent thread is not an ordinary session on a bare reload return; } if (newSessionCwd) setTabOpenNewSession(newSessionCwd); @@ -788,11 +801,20 @@ export function AppShell() { // onCwdChange effect firing after setSelectedCwd in the sidebar suppressCwdBumpRef.current = true; } + // Kept on a match (repeated opens of one thread are idempotent); cleared only when another session is picked. + const pending = pendingAgentRef.current; + const agentSession = pending?.sessionId === session.id; + if (!agentSession) { + pendingAgentRef.current = null; + setActiveAgent(null); + } // Skip router.replace when the URL already has this session — calling // replace in production Next.js triggers a Suspense remount loop. // Tab-memory restore lands on `/` and must write `?session=` so reload // and copy-link keep this session. - if (!isRestore || new URLSearchParams(window.location.search).get("session") !== session.id) { + if (agentSession) { + router.replace(`?agent=${encodeURIComponent(pending.agentName)}`, { scroll: false }); + } else if (!isRestore || new URLSearchParams(window.location.search).get("session") !== session.id) { router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false }); } }, [activeCwd, activeFileTabId, invalidateWorkspaceRestore, router, isMobile, newSessionCwd, selectedSession]); @@ -860,6 +882,63 @@ export function AppShell() { } }, [handleSelectSession, sessionCatalog]); + const openAgent = useCallback(async (name: string) => { + let response: Response; + let data: { sessionId?: string; lastReadEntryId?: string | null; error?: string }; + try { + response = await fetch(`/api/agents/${encodeURIComponent(name)}/thread`, { method: "POST" }); + data = await response.json() as typeof data; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("[pi-web] failed to open agent:", message); + window.alert(translate("agents.error", { error: message })); + return; + } + if (!response.ok || !data.sessionId) { + console.error("[pi-web] failed to open agent:", data.error); + window.alert(translate("agents.error", { error: data.error ?? `HTTP ${response.status}` })); + return; + } + const detail = await fetch(`/api/agents/${encodeURIComponent(name)}`, { cache: "no-store" }) + .then((reply) => reply.json() as Promise<{ agent?: AgentDetail }>) + .catch(() => ({} as { agent?: AgentDetail })); + setAgentDetail(detail.agent ?? null); + setActiveAgent(name); + setAgentUnreadMarker(data.lastReadEntryId ?? null); + pendingAgentRef.current = { sessionId: data.sessionId, agentName: name }; + await handleOpenSession(data.sessionId); + if (!isMobile) setRightPanelOpen(true); + }, [handleOpenSession, isMobile, translate]); + + // The one place that POSTs a reset: the profile dialog and /new, /clear both land here (the confirm is here too). + const resetAgentThread = useCallback(async (name: string) => { + if (!window.confirm(translate("agents.profile.resetConfirm", { name }))) return; + let response: Response; + try { + response = await fetch(`/api/agents/${encodeURIComponent(name)}/thread/reset`, { method: "POST" }); + } catch (error) { + window.alert(translate("agents.error", { error: error instanceof Error ? error.message : String(error) })); + return; + } + if (response.status === 409) { window.alert(translate("agents.profile.running")); return; } + if (!response.ok) { + const data = await response.json().catch(() => ({})) as { error?: string }; + window.alert(translate("agents.error", { error: data.error ?? `HTTP ${response.status}` })); + return; + } + pendingAgentRef.current = null; // the old thread id must not keep the agent view open on the archived session + setAgentUnreadMarker(null); + reloadAgents(); + await openAgent(name); + }, [openAgent, reloadAgents, translate]); + + useEffect(() => { + if (agentMountOpenedRef.current || !initialNavigation.agentName) return; + agentMountOpenedRef.current = true; + void openAgent(initialNavigation.agentName); + // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once for the ?agent= the page loaded with + }, []); + // Called by ChatWindow when a new session gets its real id from pi const handleSessionCreated = useCallback((session: SessionInfo, sourceDraftKey: string) => { setRefreshKey((k) => k + 1); @@ -1041,6 +1120,29 @@ export function AppShell() { } }, [invalidateWorkspaceRestore, selectedSession, router]); + const handleAgentDeleted = useCallback(() => { + invalidateWorkspaceRestore(); + setActiveAgent(null); + setAgentDetail(null); + setAgentUnreadMarker(null); + pendingAgentRef.current = null; + reloadAgents(); + setRefreshKey((k) => k + 1); + if (selectedSession) clearTabOpenSession(selectedSession.id); + activeNewSessionDraftKeyRef.current = null; + setSelectedSession(null); + setNewSessionCwd(null); + setSessionKey((k) => k + 1); + setBranchTree([]); + setBranchActiveLeafId(null); + setBranchSwitchLocked(false); + setSystemPrompt(null); + setSystemTools(null); + setSystemInfoLoading(false); + setActiveTopPanel(null); + router.replace("/", { scroll: false }); + }, [invalidateWorkspaceRestore, reloadAgents, selectedSession, router]); + const handleOpenFile = useCallback(( filePath: string, fileName: string, @@ -1188,9 +1290,20 @@ export function AppShell() { if (cwd === projectTrustCwd) setProjectTrust(status); }, [projectTrustCwd]); + useEffect(() => { + if (!activeAgent) setAgentDetail(null); + }, [activeAgent]); + + const markAgentRead = useCallback((entryId: string) => { + if (!activeAgent) return; + void fetch(`/api/agents/${encodeURIComponent(activeAgent)}/read`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entryId }) }) + .then(reloadAgents) + .catch(() => {}); + }, [activeAgent, reloadAgents]); + const activeFileTab = fileTabs.find((tab) => tab.id === activeFileTabId) ?? null; const activeCwdName = activeCwd ? getFileName(activeCwd) || activeCwd : null; - const windowTitle = activeCwdName ? `${activeCwdName} - Pi Web` : "Pi Web"; + const windowTitle = activeAgent ? `${activeAgent} - Pi Web` : activeCwdName ? `${activeCwdName} - Pi Web` : "Pi Web"; useEffect(() => { const syncWindowTitle = () => { @@ -1203,14 +1316,40 @@ export function AppShell() { return () => observer.disconnect(); }, [windowTitle]); - const sidebarContent = ( + const agentSpaceLeft = activeAgent && agentDetail ? ( + void handleOpenSession(sessionId)} + onProfileSaved={(agent) => { setAgentDetail(agent); reloadAgents(); }} + onDeleted={handleAgentDeleted} + onThreadReset={() => void resetAgentThread(agentDetail.name)} + /> + ) : null; + const agentSpaceRight = activeAgent && agentDetail ? ( + void handleOpenSession(sessionId)} + /> + ) : null; + + const showAgentPanel = Boolean(agentSpaceRight) && !activeFileTab && !terminalTabs.some((tab) => tab.id === activeFileTabId); + + const sidebarContent = agentSpaceLeft ? ( + <> + {agentSpaceLeft} + {isMobile && agentSpaceRight} + + ) : ( <> + {!isMobile && void openAgent(name)} + onNewAgent={() => setNewAgentOpen(true)} + onShowSessions={() => { setActiveAgent(null); setSidebarOpen(true); }} + orientation={isMobile ? "horizontal" : "vertical"} + />} + {/* Left sidebar */}
+ {isMobile && void openAgent(name)} + onNewAgent={() => setNewAgentOpen(true)} + onShowSessions={() => { setActiveAgent(null); setSidebarOpen(true); }} + orientation={isMobile ? "horizontal" : "vertical"} + />} {/* Top bar with sidebar toggle */}
@@ -1991,6 +2147,21 @@ export function AppShell() { )} + {activeAgent && agentDetail && ( + + + {!isMobile && {agentDetail.name}} + + )} + {isMobile && activeAgent && ( + + )} {isMobile && (
{ if (activeCwd) handleNewSession(`cmd-${Date.now()}`, activeCwd); }} + onResetThread={resetAgentThread} onContextUsageChange={handleContextUsageChange} onOpenFile={handleOpenLinkedFile} onOpenSession={handleOpenSession} @@ -2370,6 +2543,8 @@ export function AppShell() { onSoundToggle={onSoundToggle} playDoneSound={playDoneSound} unlockAudio={unlockAudio} + unreadMarkerEntryId={activeAgent ? agentUnreadMarker : null} + onLatestEntryViewed={activeAgent ? markAgentRead : undefined} /> ) : initialCwdStatus === "validating" ? (
{/* Right panel tab bar */} -
-
+
} {/* Only the active viewer is mounted. Lightweight per-tab state is restored on activation. */}
- {activeFileTab?.filePath ? ( + {showAgentPanel ? ( +
{agentSpaceRight}
+ ) : activeFileTab?.filePath ? ( )} + {newAgentOpen && ( + setNewAgentOpen(false)} + agentsHomeDir={agentsHomeDir} + onCreated={(agent) => { reloadAgents(); void openAgent(agent.name); }} + /> + )} {/* After Settings, so it opens above it (z-index 1100 over 1000) when Settings › MCP asks for it. */} {projectTrustDialogOpen && projectTrustCwd && ( { test("all active-session transitions share one persistence effect", () => { assert.match( source, - /useEffect\(\(\) => \{\s+if \(selectedSession\) \{[\s\S]*?setLastOpenSession\(projectKey, selectedSession\.id\);\s+setTabOpenSession\(selectedSession\.id\);\s+return;\s+\}\s+if \(newSessionCwd\) setTabOpenNewSession\(newSessionCwd\);\s+\}, \[newSessionCwd, selectedSession\]\);/, + /useEffect\(\(\) => \{\s+if \(selectedSession\) \{[\s\S]*?setLastOpenSession\(projectKey, selectedSession\.id\);\s+if \(pendingAgentRef\.current\?\.sessionId !== selectedSession\.id\) setTabOpenSession\(selectedSession\.id\);[^\n]*\s+return;\s+\}\s+if \(newSessionCwd\) setTabOpenNewSession\(newSessionCwd\);\s+\}, \[newSessionCwd, selectedSession\]\);/, ); }); @@ -88,6 +88,7 @@ test("New restores the draft after session navigation and workspace auto-restore activeProjectKeyRef: { current: cwd }, workspaceRestoreTokenRef: { current: 0 }, suppressCwdBumpRef: { current: false }, + pendingAgentRef: { current: null }, branchLeafChangeFnRef: { current: null }, liveFollowFrameRef: { current: null }, bashRecoveryIdRef: { current: 0 }, diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 884e6407ef..a7f97b6c0e 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -31,6 +31,7 @@ import { useEnterSendMode } from "@/hooks/useEnterSendMode"; import { useI18n } from "@/hooks/useI18n"; import { useChatAppearance } from "@/hooks/useChatAppearance"; import type { ToolPreset } from "@/lib/tool-presets"; +import { AgentProfileSelector } from "./AgentProfileSelector"; import { SelectorRow } from "./SelectorRow"; import { ModelSelector, type ModelSelectorOption } from "./ModelSelector"; @@ -71,6 +72,10 @@ interface Props { compactResult?: CompactResultInfo | null; toolPreset?: ToolPreset; onToolPresetChange?: (preset: ToolPreset) => void; + /** The agent profile the session runs as; null is the default agent. */ + agentProfile?: string | null; + /** Present only while a new session can still choose its agent profile. */ + onAgentProfileChange?: (profile: string | null) => void; thinkingLevel?: "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; /** New session has not committed a thinking level; the button still shows the resolved default. */ isAutoThinkingSelection?: boolean; @@ -241,6 +246,8 @@ const BUILTIN_SLASH_COMMANDS: BuiltinSlashCommand[] = [ { name: "session", description: "chat.commandSession", source: "builtin", availableWhileStreaming: true }, { name: "copy", description: "chat.commandCopy", source: "builtin", availableWhileStreaming: true }, { name: "clone", description: "chat.commandClone", source: "builtin" }, + { name: "new", description: "chat.commandNew", source: "builtin" }, + { name: "clear", description: "chat.commandClear", source: "builtin" }, ]; function getBuiltinSlashCommand(message: string): BuiltinSlashCommand | undefined { @@ -587,7 +594,7 @@ export function ModelScopeWarningBanner({ warnings }: { warnings?: string[] }) { export const ChatInput = forwardRef(function ChatInput({ onSend, onAbort, onSteer, onFollowUp, isStreaming, model, isAutoModelSelection, modelNames, modelList, modelError, modelScopeWarnings, onModelChange, modelSwitching, defaultModel, onSetDefaultModel, - onCompact, onAbortCompaction, isCompacting, compactError, compactResult, toolPreset, onToolPresetChange, + onCompact, onAbortCompaction, isCompacting, compactError, compactResult, toolPreset, onToolPresetChange, agentProfile = null, onAgentProfileChange, thinkingLevel, isAutoThinkingSelection = false, onThinkingLevelChange, availableThinkingLevels, thinkingLevelMap, savedDefaultThinkingLevel, onSetDefaultThinkingLevel, retryInfo, queuedMessages, inputHistory = [], onRecallQueue, @@ -2568,6 +2575,15 @@ export const ChatInput = forwardRef(function ChatInput({ )}
)} + {!isStreaming && ( + + )} {!isStreaming && onToolPresetChange && (
+ )} +
+ {webhook &&
{data.summary}
} +
+ ); +} diff --git a/components/agents/AgentMemory.tsx b/components/agents/AgentMemory.tsx new file mode 100644 index 0000000000..3f1173169f --- /dev/null +++ b/components/agents/AgentMemory.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { type CSSProperties, useState } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { formatRelativeTime } from "@/lib/i18n/format"; +import type { StagedFactView } from "@/lib/agent-ops/memory-review"; +import { requestTaskAction } from "./task-view"; + +const smallButton: CSSProperties = { padding: "2px 10px", borderRadius: 6, fontSize: 11, border: "1px solid var(--border)", background: "none", color: "var(--text-muted)", cursor: "pointer" }; + +function FactRow({ fact, onChanged }: { fact: StagedFactView; onChanged: () => void }) { + const { locale, t } = useI18n(); + const [error, setError] = useState(null); + + const decide = async (approved: boolean) => { + const failure = await requestTaskAction(`/api/agent-ops/memory/${fact.id}/decision`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approved }), + }); + setError(failure); + if (!failure) onChanged(); + }; + + return ( +
  • +
    {fact.text}
    +
    + {formatRelativeTime(fact.createdAt, locale)} + {fact.decision ? ( + {t(`agentOps.memory.${fact.decision}`)} + ) : ( + + + + + )} +
    + {error &&
    {t("agentOps.actionFailed", { error })}
    } +
  • + ); +} + +/** Approval queue of one agent's staged memories (expandable, or a plain list when `open`); text is rendered as plain text. */ +export function AgentMemory({ facts, onChanged, open = false }: { facts: readonly StagedFactView[]; onChanged: () => void; open?: boolean }) { + const { t } = useI18n(); + if (facts.length === 0) return null; + if (open) { + return ( +
      + {facts.map((fact) => )} +
    + ); + } + const pending = facts.filter((fact) => fact.decision === null).length; + return ( +
    + {t("agentOps.memory.summary", { pending, total: facts.length })} +
      + {facts.map((fact) => )} +
    +
    + ); +} diff --git a/components/agents/AgentMemoryRecent.tsx b/components/agents/AgentMemoryRecent.tsx new file mode 100644 index 0000000000..0ff7d6aa61 --- /dev/null +++ b/components/agents/AgentMemoryRecent.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { type CSSProperties, useEffect, useState } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { formatRelativeTime } from "@/lib/i18n/format"; +import type { AgentMemoryItem } from "@/lib/agents/memory"; +import { requestTaskAction } from "./task-view"; + +const smallButton: CSSProperties = { padding: "2px 10px", borderRadius: 6, fontSize: 11, border: "1px solid var(--border)", background: "none", color: "var(--text-muted)", cursor: "pointer" }; +// A request still pending after this long was refused or is stuck: show the item normally again. +const FORGET_GIVE_UP_MS = 2 * 60 * 1000; + +/** Recent memories of one agent (pi-mem0 snapshot); text is rendered as plain text. */ +export function AgentMemoryRecent({ agentName, items, pending, onChanged }: { agentName: string; items: readonly AgentMemoryItem[]; pending: readonly string[]; onChanged: () => void }) { + const { locale, t } = useI18n(); + const [error, setError] = useState(null); + const [gaveUp, setGaveUp] = useState>(new Set()); + + // Keyed by content: the poll hands over a new array each time, which must not restart the timers. + const pendingKey = pending.join("\n"); + useEffect(() => { + const timers = pendingKey.split("\n").filter((id) => id && !gaveUp.has(id)).map((id) => setTimeout(() => setGaveUp((prev) => new Set(prev).add(id)), FORGET_GIVE_UP_MS)); + return () => timers.forEach(clearTimeout); + }, [pendingKey, gaveUp]); + + const forget = async (id: string) => { + if (!window.confirm(t("agents.memory.forgetConfirm"))) return; + const failure = await requestTaskAction(`/api/agents/${encodeURIComponent(agentName)}/memory/forget`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ memoryId: id }), + }); + setError(failure); + if (!failure) { + setGaveUp((prev) => { const next = new Set(prev); next.delete(id); return next; }); + onChanged(); + } + }; + + const isForgetting = (id: string) => pending.includes(id) && !gaveUp.has(id); + + if (items.length === 0) return
    {t("agents.memory.none")}
    ; + return ( + <> +
      + {items.map((item) => ( +
    • +
      {item.text}
      +
      + {formatRelativeTime(item.createdAt, locale)} + {isForgetting(item.id) + ? {t("agents.memory.forgetting")} + : } +
      +
    • + ))} +
    + {error &&
    {t("agents.error", { error })}
    } + + ); +} diff --git a/components/agents/AgentProfileDialog.tsx b/components/agents/AgentProfileDialog.tsx new file mode 100644 index 0000000000..20d30a1e34 --- /dev/null +++ b/components/agents/AgentProfileDialog.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { type FormEvent, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useI18n } from "@/hooks/useI18n"; +import { openStackedDialog } from "@/lib/stacked-dialog"; +import type { AgentDetail } from "@/lib/agents/agent-view"; +import type { ToolsPreset } from "@/lib/agents/registry"; +import { backdropStyle, buttonStyle, fieldStyle, formStyle, labelStyle } from "./dialog-styles"; +import { COLORS, EMOJIS, THINKING_LEVELS, TOOLS_PRESETS, type ModelOption } from "./NewAgentDialog"; + +export function AgentProfileDialog({ agent, onClose, onSaved, onDeleted, onThreadReset }: { agent: AgentDetail; onClose: () => void; onSaved: (agent: AgentDetail) => void; onDeleted: () => void; onThreadReset: () => void }) { + const { t } = useI18n(); + const [emoji, setEmoji] = useState(agent.avatar.emoji); + const [color, setColor] = useState(agent.avatar.color); + const [role, setRole] = useState(agent.role); + const [model, setModel] = useState(agent.model ?? ""); + const [thinking, setThinking] = useState(agent.thinking ?? ""); + const [toolsPreset, setToolsPreset] = useState(agent.toolsPreset); + const [modelList, setModelList] = useState([]); + const [fetchedMcp, setFetchedMcp] = useState([]); + const [mcpServers, setMcpServers] = useState(agent.mcpServers); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const dialogRef = useRef(null); + + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + useEffect(() => openStackedDialog(document, dialogRef.current, () => onCloseRef.current()), []); + + useEffect(() => { + const controller = new AbortController(); + void (async () => { + try { + const home = await (await fetch("/api/home", { signal: controller.signal })).json() as { home?: string }; + if (!home.home) return; + const response = await fetch(`/api/models?cwd=${encodeURIComponent(home.home)}`, { cache: "no-store", signal: controller.signal }); + const data = await response.json() as { modelList?: ModelOption[] }; + if (response.ok && data.modelList) setModelList(data.modelList); + } catch { /* the default model stays the only option */ } + })(); + return () => controller.abort(); + }, []); + + useEffect(() => { + const controller = new AbortController(); + void (async () => { + try { + const response = await fetch("/api/agents/mcp-servers", { cache: "no-store", signal: controller.signal }); + const data = await response.json() as { servers?: string[] }; + if (response.ok && Array.isArray(data.servers)) setFetchedMcp(data.servers); + } catch { /* no list: only the agent's own servers show */ } + })(); + return () => controller.abort(); + }, []); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + try { + const patch: Record = {}; + if (role !== agent.role) patch.role = role; + if (toolsPreset !== agent.toolsPreset) patch.toolsPreset = toolsPreset; + if (emoji !== agent.avatar.emoji || color !== agent.avatar.color) patch.avatar = { emoji, color }; + if ((model || undefined) !== agent.model) patch.model = model || null; + if ((thinking || undefined) !== agent.thinking) patch.thinking = thinking || null; + if (mcpServers.length !== agent.mcpServers.length || mcpServers.some((name) => !agent.mcpServers.includes(name))) patch.mcpServers = mcpServers; + const response = await fetch(`/api/agents/${encodeURIComponent(agent.name)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch) }); + const data = await response.json().catch(() => ({})) as { agent?: AgentDetail; error?: string }; + if (response.status === 409) { setError(t("agents.profile.running")); return; } + if (!response.ok || !data.agent) { setError(t("agents.error", { error: data.error ?? `HTTP ${response.status}` })); return; } + onSaved(data.agent); + onClose(); + } catch (cause) { + setError(t("agents.error", { error: cause instanceof Error ? cause.message : String(cause) })); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + if (!window.confirm(t("agents.profile.deleteConfirm", { name: agent.name }))) return; + setBusy(true); + setError(null); + try { + const response = await fetch(`/api/agents/${encodeURIComponent(agent.name)}`, { method: "DELETE" }); + const data = await response.json().catch(() => ({})) as { error?: string }; + if (response.status === 409) { setError(t("agents.profile.running")); return; } + if (!response.ok) { setError(t("agents.error", { error: data.error ?? `HTTP ${response.status}` })); return; } + onDeleted(); + onClose(); + } catch (cause) { + setError(t("agents.error", { error: cause instanceof Error ? cause.message : String(cause) })); + } finally { + setBusy(false); + } + }; + + const toggleMcp = (server: string) => setMcpServers((current) => (current.includes(server) ? current.filter((name) => name !== server) : [...current, server])); + const mcpNames = [...new Set([...fetchedMcp, ...mcpServers])].sort(); + const title = t("agents.profile.title", { name: agent.name }); + const swatch = (selected: boolean) => ({ minWidth: 28, height: 28, borderRadius: 6, cursor: "pointer", border: selected ? "2px solid var(--accent)" : "1px solid var(--border)" }); + const modelInList = !model || modelList.some((entry) => `${entry.provider}/${entry.id}` === model); + if (typeof document === "undefined") return null; + return createPortal( +
    { if (event.target === event.currentTarget) onClose(); }} style={backdropStyle}> +
    void submit(event)} style={formStyle}> + {title} + {error && {error}} +
    + {t("agents.new.avatar")} +
    + {EMOJIS.map((candidate) => ( + + ))} + setEmoji(event.target.value)} aria-label={t("agents.new.avatar")} style={{ ...fieldStyle, width: 64 }} /> +
    +
    + {COLORS.map((candidate) => ( +
    +
    +