diff --git a/.pi/memory-vault/packs/paperclip-harness/20-context/paperclipai.md b/.pi/memory-vault/packs/paperclip-harness/20-context/paperclipai.md index d8fadad..7518347 100644 --- a/.pi/memory-vault/packs/paperclip-harness/20-context/paperclipai.md +++ b/.pi/memory-vault/packs/paperclip-harness/20-context/paperclipai.md @@ -99,3 +99,84 @@ If you want, the next build can be small and specific: an `Agents>skills` tab th - context: [[packs/paperclip-harness/20-context/paperclipai|paperclipai]] - runbook: [[packs/paperclip-harness/70-runbooks/tmp-deployment-operations|paperclipai deployment operations]] - observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|paperclipai repository structure and configuration patterns]] + + +--- + +## Update (2026-11-10 21:58:38) + +## Rich discovery update (2026-11-10 21:58:38) + +Yes — I now have your question right, and I confirmed it by doing it rather than reading about it. + +## What you are asking for + +A picker like `pi-config`'s, but aimed at Paperclip agents: read your global skill and extension set, show what each agent has, and apply changes **before** the run — because Paperclip builds the Pi session folder at run time, so the TypeScript picker cannot reach in beforehand. + +That is exactly right, and it is feasible. Here is the measured path. + +## The gap, measured + +| Set | Where | Count | +|---|---|---| +| what the `pi-config` picker lists | `~/.agents/skills` | **21** | +| what Paperclip can assign | `GET /api/companies/{id}/skills` | **9** | + +Eleven skills you can see in the picker — `nixos-workflow`, `caveman`, `tuios`, `archify`, `ste-writing` and the rest — could not be given to a Paperclip agent, because Paperclip had never heard of them. That is the whole problem. + +## What works, proven in this session + +1. **Local import has one door.** `POST /api/companies/{id}/skills/import` with a path under `~/.agents` returns **403 `skill_workspace_boundary_denied`** — "Import from a configured Paperclip workspace or the company managed-skill directory". Copying the skill into `~/.paperclip/instances/default/skills//nixos-workflow` and importing that path returned **201**, with key `local/9a9eba3826/nixos-workflow`. + +2. **Assignment is per agent, and it worked.** On `Documenter`: + +``` +POST /api/agents/{id}/skills/sync +{"mode":"add","desiredSkills":["local/9a9eba3826/nixos-workflow"]} → 200 +desiredSkills: 3 → 4 … then remove → back to 3 +``` + +So the per-agent list is real and I have driven it. My earlier claim that there was one set for everyone was wrong, and this is the proof. + +3. **Extensions are the one genuine gap.** `~/.agents/extensions` has no Paperclip counterpart. The per-agent lever is `adapterConfig.command` — it defaults to `pi` and the adapter appends its own arguments — so a wrapper per agent that passes `--extension …` then `exec pi "$@"` is the equivalent of `pi-config` for Paperclip runs. + +## The picker, precisely + +Read `~/.agents/skills` and `~/.agents/extensions`, plus the company list and the agent's `desiredSkills`. Mark each: *available but not in Paperclip · in Paperclip · on this agent*. Choosing an unavailable skill copies it into the managed directory, imports it, then syncs it onto the agent. Removing is a `sync` with `remove`. For an extension, add it to that agent's wrapper and point `adapterConfig.command` at it. + +Every step of that is verified above. It is one command or one panel, not a new subsystem. + +**Change I made while checking:** `nixos-workflow` is now imported into the company (copied into the managed directory), so it can be assigned to any agent. It is assigned to nobody, and `Documenter`'s skills are back exactly as they were. + +## History — your instinct is right + +`~/.pi/paperclips/-.jsonl` — 178 files, one per run, and `task-sessions` maps task → file. So the cheap version is enough: a tool-pane entry that opens that folder in Yazi, or a pane running `pi --resume`. Minutes of work, nothing invented. A transcript tab inside `Agents>agents` is the integrated option, worth doing only if browsing files becomes a detour in practice. + +Committed as `e44b0df`, documentation only — the running layout is untouched. + +## Source paths observed + +- `paperclipai/paperclip/agentmail` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/obsidian-cli` +- `paperclipai/paperclip/paperclip` +- `paperclipai/paperclip/paperclip-board` +- `paperclipai/paperclip/paperclip-converting-plans-to-tasks` +- `paperclipai/paperclip/paperclip-create-agent` +- `paperclipai/paperclip/para-memory-files` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/project-docs` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/system-architect` +- `local/9a9eba3826/nixos-workflow` +- `.paperclip/instances/default/skills` + +## Evidence + +- Confidence: 84% +- Reason: Update component/repository context with detailed discovered structure and configuration + +## Related learned memories + +- runbook: [[packs/paperclip-harness/70-runbooks/tmp-deployment-operations|Add an existing ~/.agents skill to a Paperclip company and assign it to an agent]] +- session: [[packs/paperclip-harness/80-sessions/paperclip-agent-skill-extension-picker-feasibility-measured-|Paperclip agent skill/extension picker feasibility: measured API path and boundaries]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-paperclip-2026-11-10-21-58-38|Rich discovery: .paperclip 2026-11-10 21:58:38]] +- context: [[packs/paperclip-harness/20-context/paperclipai|.paperclip]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|.paperclip repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md b/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md index 7308bb5..551c0f6 100644 --- a/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md +++ b/.pi/memory-vault/packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns.md @@ -1890,3 +1890,84 @@ If you want, the next build can be small and specific: an `Agents>skills` tab th - context: [[packs/paperclip-harness/20-context/paperclipai|paperclipai]] - runbook: [[packs/paperclip-harness/70-runbooks/tmp-deployment-operations|paperclipai deployment operations]] - observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|paperclipai repository structure and configuration patterns]] + + +--- + +## Update (2026-11-10 21:58:38) + +## Structural/configuration observations (2026-11-10 21:58:38) + +Yes — I now have your question right, and I confirmed it by doing it rather than reading about it. + +## What you are asking for + +A picker like `pi-config`'s, but aimed at Paperclip agents: read your global skill and extension set, show what each agent has, and apply changes **before** the run — because Paperclip builds the Pi session folder at run time, so the TypeScript picker cannot reach in beforehand. + +That is exactly right, and it is feasible. Here is the measured path. + +## The gap, measured + +| Set | Where | Count | +|---|---|---| +| what the `pi-config` picker lists | `~/.agents/skills` | **21** | +| what Paperclip can assign | `GET /api/companies/{id}/skills` | **9** | + +Eleven skills you can see in the picker — `nixos-workflow`, `caveman`, `tuios`, `archify`, `ste-writing` and the rest — could not be given to a Paperclip agent, because Paperclip had never heard of them. That is the whole problem. + +## What works, proven in this session + +1. **Local import has one door.** `POST /api/companies/{id}/skills/import` with a path under `~/.agents` returns **403 `skill_workspace_boundary_denied`** — "Import from a configured Paperclip workspace or the company managed-skill directory". Copying the skill into `~/.paperclip/instances/default/skills//nixos-workflow` and importing that path returned **201**, with key `local/9a9eba3826/nixos-workflow`. + +2. **Assignment is per agent, and it worked.** On `Documenter`: + +``` +POST /api/agents/{id}/skills/sync +{"mode":"add","desiredSkills":["local/9a9eba3826/nixos-workflow"]} → 200 +desiredSkills: 3 → 4 … then remove → back to 3 +``` + +So the per-agent list is real and I have driven it. My earlier claim that there was one set for everyone was wrong, and this is the proof. + +3. **Extensions are the one genuine gap.** `~/.agents/extensions` has no Paperclip counterpart. The per-agent lever is `adapterConfig.command` — it defaults to `pi` and the adapter appends its own arguments — so a wrapper per agent that passes `--extension …` then `exec pi "$@"` is the equivalent of `pi-config` for Paperclip runs. + +## The picker, precisely + +Read `~/.agents/skills` and `~/.agents/extensions`, plus the company list and the agent's `desiredSkills`. Mark each: *available but not in Paperclip · in Paperclip · on this agent*. Choosing an unavailable skill copies it into the managed directory, imports it, then syncs it onto the agent. Removing is a `sync` with `remove`. For an extension, add it to that agent's wrapper and point `adapterConfig.command` at it. + +Every step of that is verified above. It is one command or one panel, not a new subsystem. + +**Change I made while checking:** `nixos-workflow` is now imported into the company (copied into the managed directory), so it can be assigned to any agent. It is assigned to nobody, and `Documenter`'s skills are back exactly as they were. + +## History — your instinct is right + +`~/.pi/paperclips/-.jsonl` — 178 files, one per run, and `task-sessions` maps task → file. So the cheap version is enough: a tool-pane entry that opens that folder in Yazi, or a pane running `pi --resume`. Minutes of work, nothing invented. A transcript tab inside `Agents>agents` is the integrated option, worth doing only if browsing files becomes a detour in practice. + +Committed as `e44b0df`, documentation only — the running layout is untouched. + +## Source paths observed + +- `paperclipai/paperclip/agentmail` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/obsidian-cli` +- `paperclipai/paperclip/paperclip` +- `paperclipai/paperclip/paperclip-board` +- `paperclipai/paperclip/paperclip-converting-plans-to-tasks` +- `paperclipai/paperclip/paperclip-create-agent` +- `paperclipai/paperclip/para-memory-files` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/project-docs` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/system-architect` +- `local/9a9eba3826/nixos-workflow` +- `.paperclip/instances/default/skills` + +## Evidence + +- Confidence: 82% +- Reason: Captured factual repo structure/configuration patterns from rich discovery + +## Related learned memories + +- runbook: [[packs/paperclip-harness/70-runbooks/tmp-deployment-operations|Add an existing ~/.agents skill to a Paperclip company and assign it to an agent]] +- session: [[packs/paperclip-harness/80-sessions/paperclip-agent-skill-extension-picker-feasibility-measured-|Paperclip agent skill/extension picker feasibility: measured API path and boundaries]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-paperclip-2026-11-10-21-58-38|Rich discovery: .paperclip 2026-11-10 21:58:38]] +- context: [[packs/paperclip-harness/20-context/paperclipai|.paperclip]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|.paperclip repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/70-runbooks/tmp-deployment-operations.md b/.pi/memory-vault/packs/paperclip-harness/70-runbooks/tmp-deployment-operations.md index a8c67f5..d9e18a9 100644 --- a/.pi/memory-vault/packs/paperclip-harness/70-runbooks/tmp-deployment-operations.md +++ b/.pi/memory-vault/packs/paperclip-harness/70-runbooks/tmp-deployment-operations.md @@ -600,3 +600,112 @@ If you want, the next build can be small and specific: an `Agents>skills` tab th - context: [[packs/paperclip-harness/20-context/paperclipai|paperclipai]] - runbook: [[packs/paperclip-harness/70-runbooks/tmp-deployment-operations|paperclipai deployment operations]] - observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|paperclipai repository structure and configuration patterns]] + + +--- + +## Update (2026-11-10 21:58:38) + +Trigger: user wants a skill listed in the local pi-config picker (`~/.agents/skills`) to be usable by/assigned to a Paperclip agent. + +Prerequisites: companyId known; skill present under `~/.agents/skills/`; API auth as used by the harness bash tool. + +Steps: +1. Copy the skill into the company managed-skill directory: `~/.paperclip/instances/default/skills//`. Direct import of the `~/.agents` path is blocked. +2. Import: `POST /api/companies/{companyId}/skills/import` body `{"source":""}` → expect 201 with a key like `local/9a9eba3826/`. +3. Assign per agent: `POST /api/agents/{agentId}/skills/sync` body `{"mode":"add","desiredSkills":[""]}` → 200. Use `"mode":"remove"` to unassign. +4. Verify by re-reading the agent's skills; count should change by exactly the delta. + +Dangerous/pitfalls: +- Do NOT attempt `POST /skills/import` with `source` under `~/.agents`; it is rejected (403 `skill_workspace_boundary_denied`). +- `POST /api/companies/{companyId}/skills` create accepts only minimal `name` required; prefer import for existing skills. + +Rollback: sync `{"mode":"remove","desiredSkills":[""]}` on the agent; the imported company skill remains (import only, harmless if unassigned). + +Extension equivalent: no assignments exist for extensions; create a per-agent wrapper that passes `--extension ` then `exec pi "$@"` and point `adapterConfig.command` at it. + +## Evidence + +- Confidence: 90% +- Reason: Repeatable procedure with a proven workaround and rollback, useful for future skill management tasks. + + +--- + +## Update (2026-11-10 21:58:38) + +## Deployment/runbook details discovered (2026-11-10 21:58:38) + +Yes — I now have your question right, and I confirmed it by doing it rather than reading about it. + +## What you are asking for + +A picker like `pi-config`'s, but aimed at Paperclip agents: read your global skill and extension set, show what each agent has, and apply changes **before** the run — because Paperclip builds the Pi session folder at run time, so the TypeScript picker cannot reach in beforehand. + +That is exactly right, and it is feasible. Here is the measured path. + +## The gap, measured + +| Set | Where | Count | +|---|---|---| +| what the `pi-config` picker lists | `~/.agents/skills` | **21** | +| what Paperclip can assign | `GET /api/companies/{id}/skills` | **9** | + +Eleven skills you can see in the picker — `nixos-workflow`, `caveman`, `tuios`, `archify`, `ste-writing` and the rest — could not be given to a Paperclip agent, because Paperclip had never heard of them. That is the whole problem. + +## What works, proven in this session + +1. **Local import has one door.** `POST /api/companies/{id}/skills/import` with a path under `~/.agents` returns **403 `skill_workspace_boundary_denied`** — "Import from a configured Paperclip workspace or the company managed-skill directory". Copying the skill into `~/.paperclip/instances/default/skills//nixos-workflow` and importing that path returned **201**, with key `local/9a9eba3826/nixos-workflow`. + +2. **Assignment is per agent, and it worked.** On `Documenter`: + +``` +POST /api/agents/{id}/skills/sync +{"mode":"add","desiredSkills":["local/9a9eba3826/nixos-workflow"]} → 200 +desiredSkills: 3 → 4 … then remove → back to 3 +``` + +So the per-agent list is real and I have driven it. My earlier claim that there was one set for everyone was wrong, and this is the proof. + +3. **Extensions are the one genuine gap.** `~/.agents/extensions` has no Paperclip counterpart. The per-agent lever is `adapterConfig.command` — it defaults to `pi` and the adapter appends its own arguments — so a wrapper per agent that passes `--extension …` then `exec pi "$@"` is the equivalent of `pi-config` for Paperclip runs. + +## The picker, precisely + +Read `~/.agents/skills` and `~/.agents/extensions`, plus the company list and the agent's `desiredSkills`. Mark each: *available but not in Paperclip · in Paperclip · on this agent*. Choosing an unavailable skill copies it into the managed directory, imports it, then syncs it onto the agent. Removing is a `sync` with `remove`. For an extension, add it to that agent's wrapper and point `adapterConfig.command` at it. + +Every step of that is verified above. It is one command or one panel, not a new subsystem. + +**Change I made while checking:** `nixos-workflow` is now imported into the company (copied into the managed directory), so it can be assigned to any agent. It is assigned to nobody, and `Documenter`'s skills are back exactly as they were. + +## History — your instinct is right + +`~/.pi/paperclips/-.jsonl` — 178 files, one per run, and `task-sessions` maps task → file. So the cheap version is enough: a tool-pane entry that opens that folder in Yazi, or a pane running `pi --resume`. Minutes of work, nothing invented. A transcript tab inside `Agents>agents` is the integrated option, worth doing only if browsing files becomes a detour in practice. + +Committed as `e44b0df`, documentation only — the running layout is untouched. + +## Source paths observed + +- `paperclipai/paperclip/agentmail` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/obsidian-cli` +- `paperclipai/paperclip/paperclip` +- `paperclipai/paperclip/paperclip-board` +- `paperclipai/paperclip/paperclip-converting-plans-to-tasks` +- `paperclipai/paperclip/paperclip-create-agent` +- `paperclipai/paperclip/para-memory-files` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/project-docs` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/system-architect` +- `local/9a9eba3826/nixos-workflow` +- `.paperclip/instances/default/skills` + +## Evidence + +- Confidence: 83% +- Reason: Extracted repeatable deploy/configuration procedure from rich discovery + +## Related learned memories + +- runbook: [[packs/paperclip-harness/70-runbooks/tmp-deployment-operations|Add an existing ~/.agents skill to a Paperclip company and assign it to an agent]] +- session: [[packs/paperclip-harness/80-sessions/paperclip-agent-skill-extension-picker-feasibility-measured-|Paperclip agent skill/extension picker feasibility: measured API path and boundaries]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-paperclip-2026-11-10-21-58-38|Rich discovery: .paperclip 2026-11-10 21:58:38]] +- context: [[packs/paperclip-harness/20-context/paperclipai|.paperclip]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|.paperclip repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/paperclip-agent-skill-extension-picker-feasibility-measured-.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/paperclip-agent-skill-extension-picker-feasibility-measured-.md new file mode 100644 index 0000000..ab49903 --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/paperclip-agent-skill-extension-picker-feasibility-measured-.md @@ -0,0 +1,54 @@ +--- +type: session +id: session.paperclip-harness.paperclip-agent-skill-extension-picker-feasibility-measured- +title: Paperclip agent skill/extension picker feasibility: measured API path and boundaries +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-10 +tags: + - pack/paperclip-harness + - agent-memory/session + - paperclip + - skills + - extensions + - pi-config + - api + - agent-assignment +--- + +# Paperclip agent skill/extension picker feasibility: measured API path and boundaries + +Discovery turn on a Paperclip harness workspace investigating whether a `pi-config`-style picker (global skills+extensions → per-agent assignment) can be built for Paperclip agents. + +Measured sets: +- `~/.agents/skills` = 21 skills (what the local pi-config picker lists): archify, caveman, design-build, design-explore, graphify, markitdown, nixos-workflow, npm-security, obsidian-cli, obsidian-sorter, openspec-apply-change, openspec-archive-change, openspec-explore, openspec-propose, paperclip-board, project-docs, ste-writing, system-architect, terminal-browser, tuios (and one more). +- Company skill list `GET /api/companies/{companyId}/skills` = 9 entries (mix of paperclipai/paperclip/* catalog keys and company//* local keys). +- Gap: 11+ picker-visible skills were unknown to Paperclip. + +API facts verified: +- `POST /api/companies/{companyId}/skills` (create) required only `name`; other props slug, description, markdown, categories, sharingScope enum private|company|public_link, forkedFromSkillId. +- `POST /api/companies/{companyId}/skills/import` requires `{source}`; importing a path under `~/.agents` returns HTTP 403 `skill_workspace_boundary_denied` with remediation "Import from a configured Paperclip workspace or the company managed-skill directory." +- Workaround that worked: copy skill into `~/.paperclip/instances/default/skills//` then import that path → 201, key like `local/9a9eba3826/nixos-workflow`. +- Assignment is per agent: `POST /api/agents/{id}/skills/sync` with `{"mode":"add"|"remove","desiredSkills":[...]}` → 200; verified add then remove on agent `Documenter` (desiredSkills 3→4→3). +- Extensions (`~/.agents/extensions`) have no Paperclip counterpart; per-agent lever is `adapterConfig.command` (default `pi`, adapter appends its own args) → wrapper per agent that passes `--extension …` then `exec pi "$@"`. +- Run history: `~/.pi/paperclips/-.jsonl` (178 files), with `task-sessions` mapping task→file; cheap UI option is a tool-pane entry opening that folder in Yazi or a pane running `pi --resume`. + +Cleanup state: `nixos-workflow` imported into the company and assignable, assigned to no agent; Documenter's skills restored to original 3. Committed as `e44b0df`, documentation only, running layout untouched. + +## Evidence + +- Confidence: 90% +- Reason: Final answer contains many durable, measured API/path details for a future picker implementation. + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- runbook: [[packs/paperclip-harness/70-runbooks/tmp-deployment-operations|Add an existing ~/.agents skill to a Paperclip company and assign it to an agent]] +- session: [[packs/paperclip-harness/80-sessions/paperclip-agent-skill-extension-picker-feasibility-measured-|Paperclip agent skill/extension picker feasibility: measured API path and boundaries]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-paperclip-2026-11-10-21-58-38|Rich discovery: .paperclip 2026-11-10 21:58:38]] +- context: [[packs/paperclip-harness/20-context/paperclipai|.paperclip]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|.paperclip repository structure and configuration patterns]] diff --git a/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-paperclip-2026-11-10-21-58-38.md b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-paperclip-2026-11-10-21-58-38.md new file mode 100644 index 0000000..3c37b90 --- /dev/null +++ b/.pi/memory-vault/packs/paperclip-harness/80-sessions/rich-discovery-paperclip-2026-11-10-21-58-38.md @@ -0,0 +1,100 @@ +--- +type: session +id: session.paperclip-harness.rich-discovery-paperclip-2026-11-10-21-58-38 +title: Rich discovery: .paperclip 2026-11-10 21:58:38 +status: active +source_of_truth: false +freshness: current +last_reviewed: 2026-11-10 +tags: + - pack/paperclip-harness + - agent-memory/session + - autolearn + - rich-discovery + - repo/paperclip +--- + +# Rich discovery: .paperclip 2026-11-10 21:58:38 + +Sanitized rich discovery snapshot captured automatically from the completed turn. + + +## Source paths observed + +- `paperclipai/paperclip/agentmail` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/obsidian-cli` +- `paperclipai/paperclip/paperclip` +- `paperclipai/paperclip/paperclip-board` +- `paperclipai/paperclip/paperclip-converting-plans-to-tasks` +- `paperclipai/paperclip/paperclip-create-agent` +- `paperclipai/paperclip/para-memory-files` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/project-docs` +- `company/d4921013-e5da-4d88-8016-57d5746e2d03/system-architect` +- `local/9a9eba3826/nixos-workflow` +- `.paperclip/instances/default/skills` + +## Final answer snapshot + +Yes — I now have your question right, and I confirmed it by doing it rather than reading about it. + +## What you are asking for + +A picker like `pi-config`'s, but aimed at Paperclip agents: read your global skill and extension set, show what each agent has, and apply changes **before** the run — because Paperclip builds the Pi session folder at run time, so the TypeScript picker cannot reach in beforehand. + +That is exactly right, and it is feasible. Here is the measured path. + +## The gap, measured + +| Set | Where | Count | +|---|---|---| +| what the `pi-config` picker lists | `~/.agents/skills` | **21** | +| what Paperclip can assign | `GET /api/companies/{id}/skills` | **9** | + +Eleven skills you can see in the picker — `nixos-workflow`, `caveman`, `tuios`, `archify`, `ste-writing` and the rest — could not be given to a Paperclip agent, because Paperclip had never heard of them. That is the whole problem. + +## What works, proven in this session + +1. **Local import has one door.** `POST /api/companies/{id}/skills/import` with a path under `~/.agents` returns **403 `skill_workspace_boundary_denied`** — "Import from a configured Paperclip workspace or the company managed-skill directory". Copying the skill into `~/.paperclip/instances/default/skills//nixos-workflow` and importing that path returned **201**, with key `local/9a9eba3826/nixos-workflow`. + +2. **Assignment is per agent, and it worked.** On `Documenter`: + +``` +POST /api/agents/{id}/skills/sync +{"mode":"add","desiredSkills":["local/9a9eba3826/nixos-workflow"]} → 200 +desiredSkills: 3 → 4 … then remove → back to 3 +``` + +So the per-agent list is real and I have driven it. My earlier claim that there was one set for everyone was wrong, and this is the proof. + +3. **Extensions are the one genuine gap.** `~/.agents/extensions` has no Paperclip counterpart. The per-agent lever is `adapterConfig.command` — it defaults to `pi` and the adapter appends its own arguments — so a wrapper per agent that passes `--extension …` then `exec pi "$@"` is the equivalent of `pi-config` for Paperclip runs. + +## The picker, precisely + +Read `~/.agents/skills` and `~/.agents/extensions`, plus the company list and the agent's `desiredSkills`. Mark each: *available but not in Paperclip · in Paperclip · on this agent*. Choosing an unavailable skill copies it into the managed directory, imports it, then syncs it onto the agent. Removing is a `sync` with `remove`. For an extension, add it to that agent's wrapper and point `adapterConfig.command` at it. + +Every step of that is verified above. It is one command or one panel, not a new subsystem. + +**Change I made while checking:** `nixos-workflow` is now imported into the company (copied into the managed directory), so it can be assigned to any agent. It is assigned to nobody, and `Documenter`'s skills are back exactly as they were. + +## History — your instinct is right + +`~/.pi/paperclips/-.jsonl` — 178 files, one per run, and `task-sessions` maps task → file. So the cheap version is enough: a tool-pane entry that opens that folder in Yazi, or a pane running `pi --resume`. Minutes of work, nothing invented. A transcript tab inside `Agents>agents` is the integrated option, worth doing only if browsing files becomes a detour in practice. + +Committed as `e44b0df`, documentation only — the running layout is untouched. + +## Evidence + +- Confidence: 86% +- Reason: Preserve full sanitized discovery detail from a rich investigation turn + +## Related + +- [[packs/paperclip-harness/00-system/pi-agent/memory-manifest|Memory Manifest]] + +## Related learned memories + +- runbook: [[packs/paperclip-harness/70-runbooks/tmp-deployment-operations|Add an existing ~/.agents skill to a Paperclip company and assign it to an agent]] +- session: [[packs/paperclip-harness/80-sessions/paperclip-agent-skill-extension-picker-feasibility-measured-|Paperclip agent skill/extension picker feasibility: measured API path and boundaries]] +- session: [[packs/paperclip-harness/80-sessions/rich-discovery-paperclip-2026-11-10-21-58-38|Rich discovery: .paperclip 2026-11-10 21:58:38]] +- context: [[packs/paperclip-harness/20-context/paperclipai|.paperclip]] +- observation: [[packs/paperclip-harness/60-observations/tmp-repository-structure-and-configuration-patterns|.paperclip repository structure and configuration patterns]] diff --git a/bin/harness b/bin/harness index 6222a19..698e444 100755 Binary files a/bin/harness and b/bin/harness differ diff --git a/docs/DOCMAP.md b/docs/DOCMAP.md index 9d32cff..9a12817 100644 --- a/docs/DOCMAP.md +++ b/docs/DOCMAP.md @@ -21,6 +21,7 @@ it is a pointer and should be deleted or replaced. | `docs/WHAT-WE-ARE-BUILDING.md` | this project | the person's own statement of the product | | `docs/UI-LAYOUT.md` | this project | what each page must show, where each fact comes from, and the three candidate layouts | | `docs/PLAN-INTERFACE.md` | this project | the plan: Paperclip object → screen, what exists, what is missing, and in what order | +| `docs/SESSIONS.md` | this project | the two kinds of session, where Pi's conversations are, and the plan for the junk TUIOS sessions | ## What is not a document of this project diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md new file mode 100644 index 0000000..7928067 --- /dev/null +++ b/docs/SESSIONS.md @@ -0,0 +1,73 @@ +# Sessions: the ones TUIOS holds, and the ones Pi writes + +Two different things are called a session. Keeping them apart is most of the answer. + +| | TUIOS session | Pi session | +|---|---|---| +| what it is | a set of workspaces and panes, run by the TUIOS daemon | one conversation, written by Pi as JSONL | +| where it lives | inside the daemon; listed by `tuios ls` | `~/.pi/paperclips/-.jsonl` | +| how it ends | you kill it, or it is resurrected after a restart | never; it is a file | +| who cleans up | nobody, automatically | nobody; files accumulate | + +## Where to look for a Pi session — the only thing you need + +``` +~/.pi/paperclips/-.jsonl 178 files on this machine +``` + +Every Paperclip run of a Pi agent writes one, named with the time it started and the agent's +id. To find the one for a task, ask Paperclip rather than guess: + +``` +GET /api/agents/{agentId}/task-sessions +→ [{ taskKey, sessionParamsJson: { cwd, sessionId: "/home/sam/.pi/paperclips/…jsonl" } }] +``` + +To open one, three options, cheapest first: + +1. `pi --resume` in any pane, and pick from Pi's own list. +2. `pi --session /home/sam/.pi/paperclips/.jsonl` opens that exact conversation — the + same flag Paperclip uses. +3. Open the folder in Yazi (`~/.pi/paperclips`), read or copy the file. + +Two writers on one session file is the only risk, so open finished runs, or treat it as a +deliberate hand-over. + +## The junk TUIOS sessions + +Measured with `tuios ls --all-hosts`: **37 sessions**, of which **21 are from the closed +project's tests** — `harness-demo-card-*` (18) and `office-test-1*` (3). The rest are yours and +should stay: `office`, `office-test-1`'s parent, `tuios coding harness`, `paperclip-harness`, +`factory`, `drone deeca ai`, `email_agent`, `nixos desktop 13 devops`, `Basketball-site`, +`agents-tutorial`, `computer_hardware`, `computer_software`, `famil_home_lab`, +`resume_projects_notes`, `thinkpad`. + +Nothing was killed: that is a destructive action and the list above is the evidence to decide +on. + +### Proposed cleanup, in order + +1. **Kill the 21 test sessions**, by name pattern, after printing the list: + + ``` + tuios ls --all-hosts | (parse names) | grep -E '^(harness-demo-card-|office-test-1)' \ + | xargs -n1 tuios kill-session + ``` + + Add a `--dry-run` that prints and does nothing, and require the pattern to be given + explicitly, so no command can ever delete "everything that looks old". +2. **A `bin/prune` command** that lists sessions with their pane counts and last-active time, + marks anything not seen for N days, and refuses to touch a session that is attached. +3. **A rule for tests**: every test that creates a session kills it in the same script. The + previous project's tests left 21 behind precisely because creating was one command and + cleaning up was not. +4. **Do not prune `paperclip-harness`.** Its nine panes are the layout; `R` in Repair rebuilds + them. If a pane is stray, close it by name — the layout is rebuilt from `bin/board`, not + from whatever happens to be open. + +### What is *not* junk, and looks like it + +- `office` has 32 panes and is attached: it is the person's main session. +- `factory`, `office-test-1` and the `harness-demo-*` sessions have one pane each and are + detached — they *are* the junk, but they are indistinguishable from a deliberately parked + session by age alone. Hence the pattern, and hence the confirmation. diff --git a/docs/WORKSPACES.md b/docs/WORKSPACES.md index a092891..6d44d6a 100644 --- a/docs/WORKSPACES.md +++ b/docs/WORKSPACES.md @@ -62,6 +62,8 @@ what it shows is never a second copy of anything. | `Agents>runs` | the company's runs, newest first: agent, task, state, what woke it, duration | built | | `Agents>log` | the chosen run's output, deltas gathered back into paragraphs — this is watching Pi work | built | | `Agents>settings` | role, job title, model, monthly budget, heartbeat — each one editable, with pause and resume | built | +| `Agents>skills` | every skill in `~/.agents`, whether Paperclip knows it, and whether this agent has it; space turns it on or off for this agent alone | built | +| `Agents>extensions` | every extension in `~/.agents/extensions`, turned on per agent by writing a wrapper and setting that agent's command | built | ## 7. Project diff --git a/tui/agents.go b/tui/agents.go index 58843e3..867e84f 100644 --- a/tui/agents.go +++ b/tui/agents.go @@ -16,6 +16,8 @@ package main import ( "encoding/json" "fmt" + "os" + "path/filepath" "sort" "strings" "time" @@ -41,6 +43,13 @@ type runsMsg struct { err error } +type pickerMsg struct { + skills []skillItem + exts []extensionItem + note string + err error +} + type runLogMsg struct { runID string text string @@ -65,7 +74,14 @@ type agentsModel struct { runAt int settingAt int scroll int - showHelp bool + + skills []skillItem + skillsAt int + exts []extensionItem + extsAt int + picking bool + pickerNote string + showHelp bool // runs are limited to this project by default; A shows every run in the // company, which is a different question and a much longer list. @@ -77,7 +93,7 @@ type agentsModel struct { width, height int } -var agentTabs = []string{"roster", "runs", "log", "settings"} +var agentTabs = []string{"roster", "runs", "log", "settings", "skills", "extensions"} func newAgentsModel(c *paperclip.Client, folder string) agentsModel { return agentsModel{client: c, folder: folder} @@ -138,6 +154,150 @@ func (m agentsModel) loadRuns(showAll bool) tea.Cmd { } } +// loadPicker reads the three lists the picker needs: the person's own skills, +// what Paperclip knows, and what this agent has. +func (m agentsModel) loadPicker() tea.Cmd { + c := m.client + agent := m.chosenAgent() + if agent == nil { + return func() tea.Msg { return pickerMsg{err: fmt.Errorf("no agent chosen")} } + } + id := agent.ID + return func() tea.Msg { + company, err := c.CompanySkills() + if err != nil { + return pickerMsg{err: err} + } + have, err := c.AgentSkills(id) + if err != nil { + return pickerMsg{err: err} + } + bySlug := map[string]string{} + for _, s := range company { + bySlug[s.Slug] = s.Key + } + on := map[string]bool{} + for _, k := range have.DesiredSkills { + on[k] = true + } + + var skills []skillItem + seen := map[string]bool{} + for _, name := range localSkills() { + item := skillItem{slug: name, name: name, global: true} + if key, ok := bySlug[name]; ok { + item.key, item.inCompany = key, true + item.onAgent = on[key] + } + skills = append(skills, item) + seen[name] = true + } + // Paperclip-only skills, which the person never put in ~/.agents. + for _, s := range company { + if seen[s.Slug] { + continue + } + skills = append(skills, skillItem{ + slug: s.Slug, name: s.Name, key: s.Key, + inCompany: true, onAgent: on[s.Key], + }) + } + + wrapped := wrapperExtensions(id) + var exts []extensionItem + for _, e := range localExtensions() { + e.on = wrapped[e.name] + exts = append(exts, e) + } + return pickerMsg{skills: skills, exts: exts} + } +} + +// toggleSkill applies one skill change to the chosen agent: import it if +// Paperclip does not know it, then add or remove it for that agent alone. +func (m agentsModel) toggleSkill(item skillItem) tea.Cmd { + c, company := m.client, m.client.Company + agent := m.chosenAgent() + if agent == nil { + return func() tea.Msg { return pickerMsg{err: fmt.Errorf("no agent chosen")} } + } + return func() tea.Msg { + key := item.key + if item.onAgent { + if err := c.SyncSkills(agent.ID, "remove", []string{key}); err != nil { + return pickerMsg{err: err, note: "could not remove " + item.slug} + } + return pickerMsg{note: item.slug + " removed"} + } + if key == "" { + // Local only: copy into the company's managed folder, then import. + dst := filepath.Join(managedSkillsDir(company), item.slug) + if err := copyTree(filepath.Join(agentsHome(), "skills", item.slug), dst); err != nil { + return pickerMsg{err: err, note: "could not copy " + item.slug} + } + k, err := c.ImportSkill(dst) + if err != nil { + return pickerMsg{err: err, note: "could not import " + item.slug} + } + key = k + } + if err := c.SyncSkills(agent.ID, "add", []string{key}); err != nil { + return pickerMsg{err: err, note: "could not add " + item.slug} + } + return pickerMsg{note: item.slug + " added"} + } +} + +// toggleExtension flips one extension for the chosen agent and rewrites its +// wrapper. Paperclip has no extension model of its own, so the wrapper is it. +func (m agentsModel) toggleExtension(i int) tea.Cmd { + agent := m.chosenAgent() + exts := append([]extensionItem(nil), m.exts...) + if agent == nil || i >= len(exts) { + return func() tea.Msg { return pickerMsg{} } + } + exts[i].on = !exts[i].on + var chosen []extensionItem + for _, e := range exts { + if e.on { + chosen = append(chosen, e) + } + } + p := &agentPicker{client: m.client} + return func() tea.Msg { + if err := writeWrapper(p, agent.ID, chosen); err != nil { + return pickerMsg{err: err, note: "could not write the wrapper"} + } + verb := "added" + if !exts[i].on { + verb = "removed" + } + return pickerMsg{note: exts[i].name + " " + verb} + } +} + +// copyTree copies a skill folder into the company's managed directory. +func copyTree(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, info.Mode()) + }) +} + func (m agentsModel) loadLog(runID string) tea.Cmd { c := m.client return func() tea.Msg { @@ -234,6 +394,9 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.tab == 1 && len(m.runs) == 0 { return m, m.loadRuns(m.showAll) } + if m.tab == 4 || m.tab == 5 { + return m, m.loadPicker() + } } return m, nil case "up", "k": @@ -254,8 +417,25 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.moveTo(m.listLen() - 1) } return m, nil + case " ": + switch m.tab { + case 4: + if m.skillsAt < len(m.skills) { + return m, m.toggleSkill(m.skills[m.skillsAt]) + } + case 5: + return m, m.toggleExtension(m.extsAt) + } + return m, nil case "enter": switch m.tab { + case 4: + if m.skillsAt < len(m.skills) { + return m, m.toggleSkill(m.skills[m.skillsAt]) + } + return m, nil + case 5: + return m, m.toggleExtension(m.extsAt) case 3: if m.settingAt >= 0 && m.settingAt < len(settingFields) { f := settingFields[m.settingAt] @@ -322,6 +502,27 @@ func (m agentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.readAt = time.Now() return m, nil + case pickerMsg: + if msg.skills != nil { + m.skills = msg.skills + m.skillsAt = min(m.skillsAt, max(0, len(m.skills)-1)) + } + if msg.exts != nil { + m.exts = msg.exts + m.extsAt = min(m.extsAt, max(0, len(m.exts)-1)) + } + m.pickerNote = msg.note + if msg.err != nil { + m.err = msg.err + } else { + m.err = nil + } + // After a change the lists are stale; read them again. + if msg.note != "" && msg.err == nil { + return m, m.loadPicker() + } + return m, nil + case runsMsg: if msg.runs != nil { m.runs = msg.runs @@ -361,6 +562,10 @@ func (m agentsModel) listLen() int { return len(m.agents) case 1: return len(m.runs) + case 4: + return len(m.skills) + case 5: + return len(m.exts) } return 0 } @@ -379,6 +584,10 @@ func (m agentsModel) move(delta int) agentsModel { m.runAt = max(0, min(m.runAt+delta, len(m.runs)-1)) case 3: m.settingAt = max(0, min(m.settingAt+delta, len(settingFields)-1)) + case 4: + m.skillsAt = max(0, min(m.skillsAt+delta, len(m.skills)-1)) + case 5: + m.extsAt = max(0, min(m.extsAt+delta, len(m.exts)-1)) } return m.follow() } @@ -622,6 +831,10 @@ func (m agentsModel) View() string { body = m.runLines(m.width - 4) case 3: body = m.settingLines(m.width - 4) + case 4: + body = m.skillLines(m.width - 4) + case 5: + body = m.extensionLines(m.width - 4) default: body = m.logLines(m.width - 4) } @@ -639,13 +852,22 @@ func (m agentsModel) View() string { from := m.scroll to := min(len(body), from+visible) - footer := hintLine([]string{ - keyHint("[ ]", "roster, runs, log"), + hints := []string{ + keyHint("[ ]", "roster, runs, log, settings, skills, extensions"), keyHint("enter", "open a run"), keyHint("p P w", "pause, resume, wake"), keyHint("r", "read again"), keyHint("?", "keys"), - }, m.width) + } + if m.tab == 4 || m.tab == 5 { + hints = []string{ + keyHint("[ ]", "switch tab"), + keyHint("space", "turn this skill or extension on or off"), + keyHint("↑↓ j k", "move"), + keyHint("r", "read again"), + } + } + footer := hintLine(hints, m.width) if m.notice != "" { footer = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.notice) } @@ -947,3 +1169,73 @@ func (m agentsModel) logLines(width int) []string { } return out } + +// skillLines is the skills tab: every skill the person has, what Paperclip +// knows about it, and whether this agent has it. Space or enter toggles. +func (m agentsModel) skillLines(width int) []string { + agent := m.chosenAgent() + name := "no agent chosen" + if agent != nil { + name = agent.Name + } + out := []string{"", dimStyle().Render(" " + name + " · space toggles the chosen skill")} + if len(m.skills) == 0 { + return append(out, mutedStyle2().Render(" reading the skill lists…")) + } + for i, s := range m.skills { + state := "not in Paperclip" + colour := active.Warning + if s.onAgent { + state = "on this agent" + colour = active.Success + } else if s.inCompany { + state = "in Paperclip" + colour = active.Muted + } + line := fmt.Sprintf("%-26s %s", truncateVisible(s.slug, 26), + lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(state)) + if i == m.skillsAt { + out = append(out, selectedStyle2().Render(padTo("▸ "+line, width))) + continue + } + out = append(out, " "+line) + } + if m.pickerNote != "" { + out = append(out, "", " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.pickerNote)) + } + out = append(out, "", dimStyle().Render(" a skill only this agent has is the point of having agents.")) + return out +} + +// extensionLines is the extensions tab. An extension is turned on for one agent +// by writing a wrapper and pointing that agent's command at it. +func (m agentsModel) extensionLines(width int) []string { + agent := m.chosenAgent() + if agent == nil { + return []string{"", mutedStyle2().Render(" no agent chosen")} + } + out := []string{"", dimStyle().Render(" " + agent.Name + " · space toggles the chosen extension")} + if len(m.exts) == 0 { + return append(out, mutedStyle2().Render(" nothing in ~/.agents/extensions")) + } + for i, e := range m.exts { + mark := "off" + colour := active.Muted + if e.on { + mark, colour = "on", active.Success + } + line := fmt.Sprintf("%-26s %s", truncateVisible(e.name, 26), + lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(mark)) + if i == m.extsAt { + out = append(out, selectedStyle2().Render(padTo("▸ "+line, width))) + continue + } + out = append(out, " "+line) + } + if m.pickerNote != "" { + out = append(out, "", " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render(m.pickerNote)) + } + out = append(out, "", dimStyle().Render(" written as a wrapper at ~/.local/share/paperclip-harness/wrappers/")) + out = append(out, dimStyle().Render(" and passed to Paperclip as this agent's command.")) + return out +} diff --git a/tui/paperclip/client.go b/tui/paperclip/client.go index c293636..db4755e 100644 --- a/tui/paperclip/client.go +++ b/tui/paperclip/client.go @@ -499,3 +499,60 @@ func (c *Client) DashboardSummary() (Dashboard, error) { err := c.do("GET", "/api/companies/"+c.Company+"/dashboard", nil, &out) return out, err } + +// AgentSkills is what an agent may use: the desired list and what is installed. +type AgentSkills struct { + DesiredSkills []string `json:"desiredSkills"` + Entries []struct { + Key string `json:"key"` + RuntimeName string `json:"runtimeName"` + State string `json:"state"` + } `json:"entries"` +} + +func (c *Client) AgentSkills(agentID string) (AgentSkills, error) { + var out AgentSkills + err := c.do("GET", "/api/agents/"+agentID+"/skills", nil, &out) + return out, err +} + +// SyncSkills sets an agent's skills. mode is add, remove or replace. +func (c *Client) SyncSkills(agentID, mode string, keys []string) error { + if !c.UsingUserKey() { + return errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") + } + return c.do("POST", "/api/agents/"+agentID+"/skills/sync", + map[string]any{"mode": mode, "desiredSkills": keys}, nil) +} + +// CompanySkill is one skill Paperclip knows about for this company. +type CompanySkill struct { + Key string `json:"key"` + Slug string `json:"slug"` + Name string `json:"name"` + SourceType string `json:"sourceType"` +} + +func (c *Client) CompanySkills() ([]CompanySkill, error) { + var out []CompanySkill + err := c.do("GET", "/api/companies/"+c.Company+"/skills", nil, &out) + return out, err +} + +// ImportSkill adds a skill that lives in the company's managed folder. +func (c *Client) ImportSkill(source string) (string, error) { + if !c.UsingUserKey() { + return "", errors.New("refusing to write with the agent key: set PAPERCLIP_BOARD_KEY") + } + var out struct { + Imported []CompanySkill `json:"imported"` + } + if err := c.do("POST", "/api/companies/"+c.Company+"/skills/import", + map[string]string{"source": source}, &out); err != nil { + return "", err + } + if len(out.Imported) > 0 { + return out.Imported[0].Key, nil + } + return "", fmt.Errorf("import returned no skill") +} diff --git a/tui/picker.go b/tui/picker.go new file mode 100644 index 0000000..7060f41 --- /dev/null +++ b/tui/picker.go @@ -0,0 +1,190 @@ +package main + +// The skill and extension picker, for Paperclip agents. +// +// pi-config gives a person a picker that turns skills and extensions on for one +// Pi session. A Paperclip agent has no such session to reach into — Paperclip +// builds the run's skill directory at run time — so the equivalent has to be +// set before the run, in Paperclip's own model: +// +// skills POST /api/agents/{id}/skills/sync, one list per agent +// extensions a per-agent wrapper passed as adapterConfig.command +// +// This file holds the data both tabs need and the writes they make. It reads +// the same source the picker reads: ~/.agents. + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// agentsHome is where the person's own skills and extensions live. +func agentsHome() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".agents") +} + +// managedSkillsDir is the one place Paperclip will import a local skill from. +// Anywhere else is refused with skill_workspace_boundary_denied. +func managedSkillsDir(company string) string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".paperclip", "instances", "default", "skills", company) +} + +// skillItem is one line in the picker: a skill, what it is, and where it stands. +type skillItem struct { + slug string + name string + key string // the Paperclip key, empty when Paperclip does not know it + onAgent bool + inCompany bool + global bool // exists in ~/.agents/skills +} + +// localSkills lists the skills in ~/.agents/skills, which is what pi-config shows. +func localSkills() []string { + entries, err := os.ReadDir(filepath.Join(agentsHome(), "skills")) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if e.IsDir() && !strings.HasPrefix(e.Name(), ".") && e.Name() != "node_modules" { + out = append(out, e.Name()) + } + } + sort.Strings(out) + return out +} + +// extensionItem is one extension that could be added to an agent's wrapper. +type extensionItem struct { + name string + path string + on bool +} + +// localExtensions lists what ~/.agents/extensions holds: directories with an +// index.ts or a package.json that names pi extensions, and loose .ts files. +func localExtensions() []extensionItem { + dir := filepath.Join(agentsHome(), "extensions") + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []extensionItem + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, ".") || name == "node_modules" || name == "package.json" { + continue + } + if e.IsDir() { + index := filepath.Join(dir, name, "index.ts") + if _, err := os.Stat(index); err == nil { + out = append(out, extensionItem{name: name, path: index}) + } + continue + } + if strings.HasSuffix(name, ".ts") { + out = append(out, extensionItem{name: strings.TrimSuffix(name, ".ts"), path: filepath.Join(dir, name)}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name }) + return out +} + +// wrapperPath is where an agent's extension wrapper lives. It is outside the +// project on purpose: a worktree is deleted, and a wrapper must not be. +func wrapperPath(agentID string) string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", "paperclip-harness", "wrappers", agentID+".sh") +} + +// wrapperExtensions reads back which extensions a wrapper currently passes. +func wrapperExtensions(agentID string) map[string]bool { + out := map[string]bool{} + b, err := os.ReadFile(wrapperPath(agentID)) + if err != nil { + return out + } + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "--extension ") { + continue + } + p := strings.TrimSpace(strings.TrimPrefix(line, "--extension ")) + // The line ends with a shell continuation backslash. + p = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(p), "\\")) + p = strings.Trim(p, `"`) + base := filepath.Base(p) + if base == "index.ts" || base == "index.js" { + // A folder extension: the name that matters is the folder's. + base = filepath.Base(filepath.Dir(p)) + } else if i := strings.Index(base, ".ts"); i > 0 { + base = base[:i] + } + out[base] = true + } + return out +} + +// writeWrapper builds the wrapper from the chosen extensions and points the +// agent's command at it. With none chosen, the command goes back to plain pi. +func writeWrapper(c *agentPicker, agentID string, chosen []extensionItem) error { + if len(chosen) == 0 { + if err := os.Remove(wrapperPath(agentID)); err != nil && !os.IsNotExist(err) { + return err + } + return c.setCommand(agentID, "pi") + } + path := wrapperPath(agentID) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + var b strings.Builder + b.WriteString("#!/bin/sh\n") + b.WriteString("# Written by the harness picker. Paperclip appends its own arguments,\n") + b.WriteString("# so this adds extensions and then hands over to pi unchanged.\n") + b.WriteString("exec pi \\\n") + for _, e := range chosen { + fmt.Fprintf(&b, " --extension %s \\\n", e.path) + } + b.WriteString(` "$@"` + "\n") + if err := os.WriteFile(path, []byte(b.String()), 0o755); err != nil { + return err + } + return c.setCommand(agentID, path) +} + +// agentPicker is the bit of the model the wrapper writer needs: a way to change +// one agent's command without disturbing the rest of its configuration. +type agentPicker struct { + client interface { + AgentRaw(string) (map[string]any, error) + UpdateAgent(string, map[string]any) error + } +} + +func (p *agentPicker) setCommand(agentID, command string) error { + full, err := p.client.AgentRaw(agentID) + if err != nil { + return err + } + cfg, _ := full["adapterConfig"].(map[string]any) + merged := map[string]any{} + for k, v := range cfg { + merged[k] = v + } + merged["command"] = command + return p.client.UpdateAgent(agentID, map[string]any{"adapterConfig": merged}) +} + +// canRun reports whether a tool exists, so a picker can say so before offering. +func canRun(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} diff --git a/tui/picker_test.go b/tui/picker_test.go new file mode 100644 index 0000000..997cd6a --- /dev/null +++ b/tui/picker_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWrapperExtensionsReadsBackWhatWasWritten(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + id := "agent-1" + chosen := []extensionItem{ + {name: "ask-user-question", path: "/x/ask-user-question/index.ts"}, + {name: "web-fetch", path: "/x/web-fetch.ts"}, + } + fc := &fakeClient{} + p := &agentPicker{client: fc} + if err := writeWrapper(p, id, chosen); err != nil { + t.Fatal(err) + } + got := wrapperExtensions(id) + if !got["ask-user-question"] || !got["web-fetch"] { + t.Fatalf("read back %v, want both", got) + } + if err := writeWrapper(p, id, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(home, ".local", "share", "paperclip-harness", "wrappers", id+".sh")); !os.IsNotExist(err) { + t.Fatalf("wrapper should be removed when nothing is chosen") + } + if fc.lastCommand != "pi" { + t.Fatalf("command should go back to pi, got %q", fc.lastCommand) + } +} + +type fakeClient struct{ lastCommand string } + +func (f *fakeClient) AgentRaw(string) (map[string]any, error) { + return map[string]any{"adapterConfig": map[string]any{}}, nil +} +func (f *fakeClient) UpdateAgent(_ string, body map[string]any) error { + cfg, _ := body["adapterConfig"].(map[string]any) + f.lastCommand, _ = cfg["command"].(string) + return nil +}