Skill and extension picker for Paperclip agents; session notes and cleanup plan

This commit is contained in:
Sam
2026-11-11 09:15:38 +11:00
parent e44b0dff51
commit 1f8731fd49
13 changed files with 1091 additions and 5 deletions

View File

@@ -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/<companyId>/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/<timestamp>-<agentId>.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 <!-- same-turn-2026-11-10 21:58:38 -->
- 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]]

View File

@@ -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/<companyId>/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/<timestamp>-<agentId>.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 <!-- same-turn-2026-11-10 21:58:38 -->
- 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]]

View File

@@ -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/<skill>`; API auth as used by the harness bash tool.
Steps:
1. Copy the skill into the company managed-skill directory: `~/.paperclip/instances/default/skills/<companyId>/<skill>`. Direct import of the `~/.agents` path is blocked.
2. Import: `POST /api/companies/{companyId}/skills/import` body `{"source":"<path under managed dir>"}` → expect 201 with a key like `local/9a9eba3826/<skill>`.
3. Assign per agent: `POST /api/agents/{agentId}/skills/sync` body `{"mode":"add","desiredSkills":["<key>"]}` → 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":["<key>"]}` 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 <path>` 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/<companyId>/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/<timestamp>-<agentId>.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 <!-- same-turn-2026-11-10 21:58:38 -->
- 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]]

View File

@@ -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/<companyId>/* 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/<companyId>/<skill>` 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/<timestamp>-<agentId>.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 <!-- same-turn-2026-11-10 21:58:38 -->
- 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]]

View File

@@ -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/<companyId>/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/<timestamp>-<agentId>.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 <!-- same-turn-2026-11-10 21:58:38 -->
- 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]]

Binary file not shown.

View File

@@ -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

73
docs/SESSIONS.md Normal file
View File

@@ -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/<timestamp>-<agentId>.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/<timestamp>-<agentId>.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/<file>.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.

View File

@@ -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

View File

@@ -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
}

View File

@@ -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")
}

190
tui/picker.go Normal file
View File

@@ -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
}

46
tui/picker_test.go Normal file
View File

@@ -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
}