diff --git a/scripts/kill-dev.sh b/scripts/kill-dev.sh index 9a53498a4ff..b6dec9d8dac 100755 --- a/scripts/kill-dev.sh +++ b/scripts/kill-dev.sh @@ -24,6 +24,8 @@ node_lines=() pg_pids=() pg_pidfiles=() pg_data_dirs=() +browser_pids=() +browser_lines=() is_pid_running() { local pid="$1" @@ -87,6 +89,14 @@ while IFS= read -r line; do node_lines+=("$line") done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true) +# --- Agent browser processes (headless Chrome from ~/.agent-browser) --- +while IFS= read -r line; do + [[ -z "$line" ]] && continue + pid=$(echo "$line" | awk '{print $2}') + browser_pids+=("$pid") + browser_lines+=("$line") +done < <(ps aux | grep -E 'agent-browser/browsers/chrome-.*/Google Chrome for Testing' | grep -v grep || true) + candidate_pidfiles=() candidate_pidfiles+=( "$HOME"/.paperclip/instances/*/db/postmaster.pid @@ -107,7 +117,7 @@ for pidfile in "${candidate_pidfiles[@]:-}"; do append_postgres_from_pidfile "$pidfile" done -if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 ]]; then +if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 && ${#browser_pids[@]} -eq 0 ]]; then echo "No Paperclip dev processes found." exit 0 fi @@ -144,6 +154,22 @@ if [[ ${#pg_pids[@]} -gt 0 ]]; then echo "" fi +if [[ ${#browser_pids[@]} -gt 0 ]]; then + echo "Found ${#browser_pids[@]} agent browser process(es):" + echo "" + + for i in "${!browser_pids[@]:-}"; do + line="${browser_lines[$i]}" + pid=$(echo "$line" | awk '{print $2}') + start=$(echo "$line" | awk '{print $9}') + cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}') + cmd=$(echo "$cmd" | sed "s|$HOME/||g") + printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd" + done + + echo "" +fi + if [[ "$DRY_RUN" == true ]]; then echo "Dry run — re-run without --dry to kill these processes." exit 0 @@ -158,6 +184,13 @@ if [[ ${#node_pids[@]} -gt 0 ]]; then sleep 2 fi +if [[ ${#browser_pids[@]} -gt 0 ]]; then + echo "Sending SIGTERM to agent browser processes..." + for pid in "${browser_pids[@]}"; do + kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone" + done +fi + leftover_pg_pids=() leftover_pg_data_dirs=() for i in "${!pg_pids[@]:-}"; do @@ -203,4 +236,13 @@ if [[ ${#pg_pids[@]} -gt 0 ]]; then done fi +if [[ ${#browser_pids[@]} -gt 0 ]]; then + for pid in "${browser_pids[@]:-}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " agent browser $pid still alive, sending SIGKILL..." + kill -KILL "$pid" 2>/dev/null || true + fi + done +fi + echo "Done." diff --git a/server/package.json b/server/package.json index a4d1407d037..dac65fa7b6d 100644 --- a/server/package.json +++ b/server/package.json @@ -32,16 +32,15 @@ "skills" ], "scripts": { - "preflight:workspace-links": "tsx ../scripts/ensure-workspace-package-links.ts", - "dev": "pnpm run preflight:workspace-links && tsx src/index.ts", - "dev:watch": "pnpm run preflight:workspace-links && cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", + "dev": "tsx src/index.ts", + "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", - "build": "pnpm run preflight:workspace-links && tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", + "build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", "prepack": "pnpm run prepare:ui-dist", "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", "start": "node dist/index.js", - "typecheck": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts index 85c9c16f6de..17738e727d7 100644 --- a/server/src/services/local-service-supervisor.ts +++ b/server/src/services/local-service-supervisor.ts @@ -184,7 +184,31 @@ export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: { const records = await listLocalServiceRegistryRecords( input.profileKind ? { profileKind: input.profileKind } : undefined, ); - return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null; + const record = records.find((entry) => entry.runtimeServiceId === input.runtimeServiceId) ?? null; + if (!record) return null; + + let candidate = record; + if (!isPidAlive(candidate.pid)) { + const ownerPid = candidate.port ? await readLocalServicePortOwner(candidate.port) : null; + if (!ownerPid) { + await removeLocalServiceRegistryRecord(candidate.serviceKey); + return null; + } + candidate = { + ...candidate, + pid: ownerPid, + processGroupId: candidate.processGroupId && isPidAlive(candidate.processGroupId) ? candidate.processGroupId : ownerPid, + lastSeenAt: new Date().toISOString(), + }; + await writeLocalServiceRegistryRecord(candidate); + } + + if (!(await isLikelyMatchingCommand(candidate))) { + await removeLocalServiceRegistryRecord(record.serviceKey); + return null; + } + + return candidate; } export function isPidAlive(pid: number) { @@ -214,7 +238,10 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) { const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]); const commandLine = stdout.trim(); if (!commandLine) return false; - return commandLine.includes(record.command) || commandLine.includes(record.serviceName); + const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim(); + const normalizedCommandLine = normalize(commandLine); + const normalizedRecordedCommand = normalize(record.command); + return normalizedCommandLine.includes(normalizedRecordedCommand) || normalizedCommandLine.includes(record.serviceName); } catch { return true; } diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index f3c07154292..7b4a1461b93 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -152,7 +152,76 @@ Practical rules: **Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. -**Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. +## Issue Dependencies (Blockers) + +Paperclip supports first-class blocker relationships between issues. Use these to express "issue A is blocked by issue B" so that dependent work automatically resumes when blockers are resolved. + +### Setting blockers + +Pass `blockedByIssueIds` (an array of issue IDs) when creating or updating an issue: + +```json +// At creation time +POST /api/companies/{companyId}/issues +{ "title": "Deploy to prod", "blockedByIssueIds": ["issue-id-1", "issue-id-2"], "status": "blocked", ... } + +// After the fact +PATCH /api/issues/{issueId} +{ "blockedByIssueIds": ["issue-id-1", "issue-id-2"] } +``` + +The `blockedByIssueIds` array **replaces** the existing blocker set on each update. To add a blocker, include the full list. To remove all blockers, send `[]`. + +Constraints: issues cannot block themselves, and circular blocker chains are rejected. + +### Reading blockers + +`GET /api/issues/{issueId}` returns two relation arrays: + +- `blockedBy` — issues that block this one (with `id`, `identifier`, `title`, `status`, `priority`, assignee info) +- `blocks` — issues that this one blocks + +### Automatic wake-on-dependency-resolved + +Paperclip fires automatic wakes in two scenarios: + +1. **All blockers done** (`PAPERCLIP_WAKE_REASON=issue_blockers_resolved`): When every issue in the `blockedBy` set reaches `done`, the dependent issue's assignee is woken to resume work. +2. **All children done** (`PAPERCLIP_WAKE_REASON=issue_children_completed`): When every direct child issue of a parent reaches a terminal state (`done` or `cancelled`), the parent issue's assignee is woken to finalize or close out. + +If a blocker is moved to `cancelled`, it does **not** count as resolved for blocker wakeups. Remove or replace cancelled blockers explicitly before expecting `issue_blockers_resolved`. + +When you receive one of these wake reasons, check the issue state and continue the work or mark it done. + +## Requesting Board Approval + +Agents can create approval requests for arbitrary issue-linked work. Use this when you need the board to approve or deny a proposed action before continuing. + +Recommended generic type: + +- `request_board_approval` for open-ended approval requests like spend approval, vendor approval, launch approval, or other board decisions + +Create the approval and link it to the relevant issue in one call: + +```json +POST /api/companies/{companyId}/approvals +{ + "type": "request_board_approval", + "requestedByAgentId": "{your-agent-id}", + "issueIds": ["{issue-id}"], + "payload": { + "title": "Approve monthly hosting spend", + "summary": "Estimated cost is $42/month for provider X.", + "recommendedAction": "Approve provider X and continue setup.", + "risks": ["Costs may increase with usage."] + } +} +``` + +Notes: + +- `issueIds` links the approval into the issue thread/UI. +- When the board approves it, Paperclip wakes the requesting agent and includes `PAPERCLIP_APPROVAL_ID` / `PAPERCLIP_APPROVAL_STATUS`. +- Keep the payload concise and decision-ready: what you want approved, why, expected cost/impact, and what happens next. ## Project Setup Workflow (CEO/Manager Common Path) @@ -350,7 +419,7 @@ PATCH /api/agents/{agentId}/instructions-path | My identity | `GET /api/agents/me` | | My compact inbox | `GET /api/agents/me/inbox-lite` | | Report a user's Mine inbox view | `GET /api/agents/me/inbox/mine?userId=:userId` | -| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` | +| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,in_review,blocked` | | Checkout task | `POST /api/issues/:issueId/checkout` | | Get task + ancestors | `GET /api/issues/:issueId` | | List issue documents | `GET /api/issues/:issueId/documents` | diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index a2d3b420f04..225ff3ec4ab 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -314,6 +314,43 @@ PATCH /api/issues/issue-200 { "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." } ``` +### Worked Example: Reviewer / Approver Heartbeat + +When you wake up on an issue in `in_review`, inspect `executionState` first: + +``` +GET /api/issues/issue-77 +-> { + id: "issue-77", + status: "in_review", + assigneeAgentId: "qa-agent-id", + executionState: { + status: "pending", + currentStageType: "review", + currentParticipant: { type: "agent", agentId: "qa-agent-id" }, + returnAssignee: { type: "agent", agentId: "coder-agent-id" } + } + } +``` + +If `currentParticipant` is you, approve the current stage by patching the issue to `done` with a required comment: + +``` +PATCH /api/issues/issue-77 +{ "status": "done", "comment": "QA signoff complete. Verified the regression and test coverage." } +``` + +Paperclip writes the execution decision automatically. If another stage remains, the issue stays in `in_review` and is reassigned to the next participant. If this was the final stage, the issue reaches actual `done`. + +To request changes, use a non-`done` status with a required comment. Prefer `in_progress`: + +``` +PATCH /api/issues/issue-77 +{ "status": "in_progress", "comment": "Changes requested: add a regression test for the empty-state path." } +``` + +Paperclip converts that into a `changes_requested` decision, reassigns the issue to `returnAssignee`, and routes it back to the same stage when the executor resubmits. + --- ## Worked Example: Manager Heartbeat