Skill and extension picker for Paperclip agents; session notes and cleanup plan
This commit is contained in:
@@ -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]]
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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]]
|
||||
@@ -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]]
|
||||
BIN
bin/harness
BIN
bin/harness
Binary file not shown.
@@ -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
73
docs/SESSIONS.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
302
tui/agents.go
302
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
|
||||
}
|
||||
|
||||
@@ -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
190
tui/picker.go
Normal 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
46
tui/picker_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user