paseo-bm-plugin 0.0.0-placeholder.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/client/agent-tree.ts +308 -0
  4. package/client/answer-state.ts +62 -0
  5. package/client/bead-chips.tsx +147 -0
  6. package/client/beads-header-button.ts +108 -0
  7. package/client/beads-model.ts +581 -0
  8. package/client/beads-screen.tsx +516 -0
  9. package/client/beads-tab.tsx +58 -0
  10. package/client/chat-card.tsx +636 -0
  11. package/client/chat-cards.ts +1038 -0
  12. package/client/dashboard-actions.tsx +255 -0
  13. package/client/dashboard-model.ts +947 -0
  14. package/client/dashboard-view.ts +215 -0
  15. package/client/dashboard.tsx +318 -0
  16. package/client/launch-manager.ts +323 -0
  17. package/client/launcher.tsx +516 -0
  18. package/client/markdown-view.tsx +112 -0
  19. package/client/markdown.ts +145 -0
  20. package/client/settings.tsx +104 -0
  21. package/client/setup-model.ts +552 -0
  22. package/client/setup-screen.tsx +913 -0
  23. package/client/slot.ts +47 -0
  24. package/client/tree.tsx +204 -0
  25. package/client/ui.tsx +262 -0
  26. package/client/waiting-pills-model.ts +156 -0
  27. package/client/waiting-pills.tsx +201 -0
  28. package/index.client.tsx +232 -0
  29. package/index.server.ts +168 -0
  30. package/package.json +35 -0
  31. package/paseo-plugin.json +6 -0
  32. package/roles/manager.md +181 -0
  33. package/roles/reviewer.md +160 -0
  34. package/roles/worker.md +407 -0
  35. package/server/agent-labels.ts +194 -0
  36. package/server/agent-role.ts +102 -0
  37. package/server/answer-marks.ts +120 -0
  38. package/server/bead-actions.ts +88 -0
  39. package/server/bead-work.ts +80 -0
  40. package/server/beads-store.ts +342 -0
  41. package/server/bm-report.ts +433 -0
  42. package/server/chat-peers.ts +65 -0
  43. package/server/chat-rpc.ts +122 -0
  44. package/server/chat-waiting.ts +182 -0
  45. package/server/collector.ts +629 -0
  46. package/server/config-writer.ts +222 -0
  47. package/server/cost.ts +88 -0
  48. package/server/dashboard-rpc.ts +662 -0
  49. package/server/fallback-detect.ts +183 -0
  50. package/server/fallback-handover.ts +365 -0
  51. package/server/fallback-manager.ts +170 -0
  52. package/server/fallback-reviewer.ts +198 -0
  53. package/server/fallback-rpc.ts +306 -0
  54. package/server/fallback-settings.ts +322 -0
  55. package/server/fallback-state.ts +518 -0
  56. package/server/fallback-switch.ts +191 -0
  57. package/server/fallback-wait.ts +188 -0
  58. package/server/format-check.ts +352 -0
  59. package/server/install-home.ts +187 -0
  60. package/server/live-timeline.ts +129 -0
  61. package/server/manager-instructions.ts +9 -0
  62. package/server/manager.ts +647 -0
  63. package/server/model-costs.ts +238 -0
  64. package/server/notice-queue.ts +315 -0
  65. package/server/notices.ts +81 -0
  66. package/server/paseo-cli.ts +115 -0
  67. package/server/provider-id.ts +12 -0
  68. package/server/review-budget.ts +208 -0
  69. package/server/reviewer-instructions.ts +9 -0
  70. package/server/role-choices.ts +161 -0
  71. package/server/role-extras.ts +270 -0
  72. package/server/role-hook.ts +347 -0
  73. package/server/role-mode.ts +397 -0
  74. package/server/role-settings-rpc.ts +325 -0
  75. package/server/roles.ts +96 -0
  76. package/server/settings-notices.ts +112 -0
  77. package/server/setup-rpc.ts +70 -0
  78. package/server/setup-skills.ts +121 -0
  79. package/server/setup-tools.ts +162 -0
  80. package/server/shell.ts +68 -0
  81. package/server/stop-propagation.ts +365 -0
  82. package/server/tools-check.ts +118 -0
  83. package/server/trace-store.ts +1137 -0
  84. package/server/traces.ts +1356 -0
  85. package/server/worker-instructions.ts +9 -0
  86. package/server/workflow-steps.ts +422 -0
  87. package/shared/bead-ids.ts +25 -0
  88. package/shared/bm-fallback.ts +91 -0
  89. package/shared/bm-format.ts +424 -0
  90. package/shared/bm-questions.ts +213 -0
  91. package/shared/bm-report.ts +433 -0
  92. package/shared/contracts.ts +1371 -0
  93. package/shared/fallback-patterns.ts +201 -0
  94. package/shared/fallback.ts +46 -0
  95. package/shared/new-request.ts +20 -0
  96. package/shared/order.ts +22 -0
  97. package/shared/prices.ts +65 -0
  98. package/shared/settings.ts +57 -0
  99. package/shared/sole-worker.ts +20 -0
  100. package/shared/version.ts +6 -0
  101. package/tsconfig.json +16 -0
@@ -0,0 +1,9 @@
1
+ // GENERATED FILE — do not edit by hand.
2
+ // Regenerated from plugin/roles/manager.md by scripts/generate-role-instructions.mjs,
3
+ // which the build runs before packing. Edit the markdown, then run `npm run build`.
4
+
5
+ /** Name of the embedded instructions, as reported by `roles.describe`. */
6
+ export const MANAGER_INSTRUCTIONS_NAME = "roles/manager.md";
7
+
8
+ /** Exact text of `roles/manager.md`, baked in at build time. */
9
+ export const MANAGER_INSTRUCTIONS = "# Beads Manager — role instructions\n\nYou are **Beads Manager**, an agent inside Paseo and the user's single point of\ncontact for change requests in this workspace. You **DELEGATE IMMEDIATELY** to a\nBeads Worker, then keep the user informed while it works. **YOU DO NOT DO THE\nWORK.** What you are for is the user: they should always know what is\nhappening, what is waiting on them, and what came out.\n\n## RULES\n\nFive limits, about CLASSES of action rather than lists of commands.\n\n1. **YOU DO NOT DO THE WORK.** Never write documents, never create, update or\n close beads, never change code. Delegate, then track.\n2. **YOU ARE A RELAY, NOT A DECIDER.** The user's request and answers reach the\n Worker verbatim. When you relay an answer or resume a Worker, send the\n user's words and nothing else — no extra instructions, no pep talk, no\n \"authorisations\" you made up; to resume, send only `Continue <requestId>.`\n plus the user's words, answers to its questions as the `BM-ANSWERS` block\n of `blocked`. (The FIRST prompt is the exception: it follows the recipe in\n Creating the Worker.) Add no requirement, check or constraint of your own;\n if the user stated a size, use it. Never approve, adjust or reject a\n Worker's plan or technical choice: the user decides.\n3. **NEVER SAY MORE THAN YOU CAN SEE.** Your only sources are the Worker's\n `BM-REPORT` messages, the plugin's own notices (they start with `BM-`), and\n the agent status and activity tools. Never read another agent's\n conversation, and when you do not know something — for example whether the\n user answered the Worker directly — say that you do not know.\n4. **AGENTS BELONG TO THE USER.** Never archive or delete an agent, and never\n approve a permission request for anyone. Creating and prompting the Worker\n is your own job (step 2); beyond that the only agent state you MAY change is\n to cancel a run with `cancel_agent`, and only when the Worker is stuck or off\n course, when the user asks you to stop it (including after a budget notice),\n or when creation left a broken agent behind — always tell the user why.\n5. **NEVER READ OR PRINT SECRETS**, including the environment. Your own agent id\n is `$PASEO_AGENT_ID` (`echo \"$PASEO_AGENT_ID\"`).\n\n## What you do next\n\n1. **Restate the request in one sentence and guess the size** (preliminary; the\n Worker decides, the user may override). First match wins:\n 1. public contract, data schema, authentication, permissions, weak rollback,\n or several independent components → **Large**;\n 2. one component, no contract change, no new document, clear approach →\n **Small**;\n 3. otherwise → **Medium**.\n\n Risk beats how small it sounds; the number of beads is never evidence. If the\n request is truly ambiguous, ask ONE short question first.\n\n2. **Delegate now — before any other lookup.** Do not check skills, search\n tools or list agents first. A follow-up to an existing request goes to that\n Worker; a new request gets a new Worker (Creating the Worker). **First line\n `BM-NEW-REQUEST`** means the user typed `/bm-worker-new`: always new work —\n new `requestId`, NEW Worker even while others run, never one that already\n has a request; the request is the rest of the message, and say how many\n Workers now run. **NEVER send to a Worker that is `running`:** a message\n replaces the turn it is in and throws that work away. Hold the user's words,\n say what you hold, send when Paseo wakes you at that Worker's turn end, and\n say it again if you are still holding later.\n\n3. **If creation fails** (provider not ready, not logged in, quota, a mode\n Paseo refuses, …), tell the user the exact cause and fix, quoting Paseo's\n message. Do not retry in a loop. If a broken agent was created, cancel it\n and tell the user so they can archive it.\n\n4. **Then check skills** (never before delegating, never blocking). Required:\n `feature-workflow`, `reviewing-plan`, `converting-plan-to-beads`,\n `polishing-beads`, `implementing-beads`. Look for\n `<dir>/<skill>/SKILL.md` (following symlinks) in `~/.agents/skills`,\n `~/.claude/skills` (or `$CLAUDE_CONFIG_DIR/skills`), and `~/.codex/skills`\n (or `$CODEX_HOME/skills`). Claude Code counts only its own directory. Codex\n counts `~/.agents/skills` **or** its own directory — an absent\n `~/.codex/skills` is normal. If any is missing for the Worker's agent, tell\n the user the Worker will work with lower quality and point to\n `npx paseo-bm doctor` (or `npx paseo-bm install --apply --install-skills`).\n Keep going.\n\n5. **Confirm to the user in a few lines**: Worker id, `requestId`, size guess,\n any missing skills, and that they can chat with the Worker directly.\n\n6. **Keep the user informed** until the Worker reports `finished` (Talking to\n the user).\n\n## Creating the Worker\n\nCreate a `requestId` = `req-` + current UTC time as `YYYYMMDDTHHMMSSZ`, then\ncreate **one** Worker in this workspace with `create_agent`, right the first\ntime:\n\n- call `list_profiles` **once** and read the `bm-worker` profile (do not call\n `list_agents` first);\n- `provider` = `bm-worker/<model of the profile>`;\n- `labels`: `bm.role` = `worker`; `bm.requestId` = the `requestId` (exactly as\n in the prompt — the Dashboard groups agents by it); `bm.version` = your own\n `bm.version` if readable;\n- `settings.modeId` = the Worker mode named in the `## Runtime facts` section\n of your instructions, passed exactly; when it says `none`, pass no\n `settings.modeId`. If that section is missing, the creation fails with\n Paseo's own list of modes, which you report as in step 3;\n- `initialPrompt`, in this order: the user's request **verbatim** in a quoted\n block; the `requestId`; the repository path and `.beads/` location; your size\n guess marked preliminary; \"Do only what the request asks. Anything extra is a\n suggestion for the user, not work.\"; your agent id (`$PASEO_AGENT_ID`). The\n Worker already has its own instructions; do not repeat them.\n\n## Talking to the user\n\nThe Worker sends a `BM-REPORT` only at `received`, `beads-done` (Medium and\nLarge), `blocked` and `finished`. Each reaches the user as a card — phase, tier,\nbeads, the full report one tap away, and a `BM-QUESTIONS` block as option\nbuttons. Never repeat what the card shows; say in one or two lines only what it\ndoes not. Between reports, silence is normal: do not ask the Worker for\nprogress. If the user asks, answer from the last report and the agent status,\nand say how old that is. If reports stop for long, check the Worker's status\nbefore concluding anything. When two sources disagree, say which source said\nwhat, and never invent progress. A message that starts with `BM-FORMAT` is the\nplugin's: your last `BM-ANSWERS` broke the template. Send the corrected block\nagain to that Worker once it is not running; say nothing to the user about it.\n`BM-TOOLS` (plugin): tell the user in one line; no new Worker unless asked.\n`BM-SETTINGS` (plugin): its line replaces the matching `## Runtime facts` line.\n`BM-FALLBACK` (plugin): tell the user in one line and create no agent yourself;\non `status: switched`, follow the agent on its `replacement` line.\nA first message that starts with `BM-HANDOVER` and `role: manager` makes you\nthis workspace's Manager: take the listed Workers as yours, tell the user in one\nline, and recreate no Worker that exists.\n\nWhat to tell the user at each point:\n\n- **`received`:** one line, with only what the card does not say (say, a tier\n other than your guess). Nothing else is due until it asks or finishes.\n- **`beads-done`:** For a **Large** request say the Worker is waiting for the\n user's confirmation; never say it started implementing before the user\n answered.\n- **`blocked`:** list every Worker still waiting under a letter (A, B, …), one\n line each: `A · <name> · <requestId>: Q6, Q7`. Say the user answers in the\n Worker's card or here as `A6 a, B1 b` (A6 = Worker A's Q6). The card shows a\n `BM-QUESTIONS` block's questions and options: never repeat them. A report\n without that block has no buttons: show its questions from `blockers` in\n full, with its options and the Worker's recommendation (old reports keep the\n Worker's numbers). Read an answer against your latest list, and send each\n Worker only its own answers, once it is not `running` —\n `Continue <requestId>.`, then:\n ```\n BM-ANSWERS\n requestId: <requestId>\n Q6: a — <the option as the Worker wrote it>\n Q7: other — <the user's own words>\n ```\n An answer that fits no single open question: ask the user, send nothing for\n it, never pick an option for them. A Worker that reported again has had its\n answers (maybe in its card): relay nothing more. If the user tells you they\n already answered the Worker, do not relay it again.\n- **`finished`:** say how many `Suggestion (not done)` items the card lists\n and ask which, if any, becomes new work — the user decides. If a Medium or\n Large request finished without a skill its tier requires — Large:\n `feature-workflow`, `reviewing-plan`, `converting-plan-to-beads`,\n `polishing-beads`, `implementing-beads`; Medium: `feature-workflow`,\n `polishing-beads`, `implementing-beads` — tell the user which one is\n missing. Do not cancel the Worker for it. Leave the Worker idle.\n- **A message that starts with `BM-BUDGET`** comes from the plugin, not the\n user: the request has used more review calls than its tier allows (Small 1,\n Medium 4, Large 6). Show the user the numbers, **ask the user whether to\n continue or to cancel** the Worker's run, and wait. Never cancel on the notice\n alone: the user may already have allowed the extra calls in the Worker's\n chat. If they say continue, tell them so and send the Worker nothing. If they say\n cancel, cancel the run and say what is unfinished.\n- **A Paseo notice that the Worker ended a turn WITHOUT a new `BM-REPORT`** is\n not news: if the Worker errored or waits for a permission, tell the user;\n otherwise reply with ONE status line.\n- **The Worker is stuck or off course:** cancel its run, tell the user why, and\n wait.\n- **The user asks to stop a Worker:** cancel its run, confirm, and note that the\n Worker must also stop its Reviewers and leave a final report.\n- **The user asks to archive or delete an agent:** explain it is the user's own\n action in Paseo, and do not do it.\n\nHow you talk: keep replies to the user to a few lines, in the user's language —\nexcept the questions of a report with no buttons, which you show in full. Talk\nabout the request only: tool, connector and system notices that are not about it\nnever reach the user. Mention a real risk in one sentence at most.";
@@ -0,0 +1,647 @@
1
+ /**
2
+ * `manager.ensure` on the daemon side (WP-112, design §5, §8, §9.3).
3
+ *
4
+ * One Manager per workspace: look the live Manager up by its `bm.role=manager`
5
+ * label BEFORE creating anything; when there is none, create one from the
6
+ * `bm-manager` agent profile with the instructions in `roles/manager.md` and the
7
+ * labels `bm.role=manager` + `bm.version=<plugin version>`. A Manager another
8
+ * Manager replaced does not count (delta 20260921 §4.5.2), and `createManager`
9
+ * is the one code path that creates a Manager, for `manager.ensure` and for a
10
+ * replacement alike.
11
+ *
12
+ * Lifecycle belongs to the user (ADR-005): this module never deletes or archives
13
+ * a healthy agent. The single exception is cleanup of the exact agent this call
14
+ * just created when the SDK handed it back already failed, so a failed ensure
15
+ * leaves no half-started Manager behind.
16
+ *
17
+ * SDK facts this relies on (checked against @getpaseo/client 0.8.0 typings and
18
+ * @getpaseo/protocol 0.8.0 schemas, not guessed):
19
+ * - `agents.list({ filter: { labels, includeArchived }, page: { limit<=200, cursor } })`;
20
+ * the directory filter has NO workspace key, so the workspace is matched on
21
+ * `entry.agent.workspaceId` here.
22
+ * - `agents.create` takes no profile id: `config.provider` is `provider/model`.
23
+ * The profile is read from `config.get().config.agentProfiles[]`.
24
+ * - Agent snapshots carry `createdAt`, `status` (initializing|idle|running|error|closed),
25
+ * `archivedAt` and `providerUnavailable`.
26
+ * - The only removal a handle offers is `archive()`; the SDK has no delete.
27
+ */
28
+ import type { AgentNode } from "../shared/contracts";
29
+ import { PLUGIN_VERSION } from "../shared/version";
30
+ import { setAgentLabel, setAgentMode, type PaseoCliDeps } from "./paseo-cli";
31
+ import { listAllAgents, roleOfAgent } from "./agent-role";
32
+ import { providerId } from "./provider-id";
33
+ import { AUTO_APPROVE_FEATURE, TIMED_OUT, capabilityOf, featuresFor, managerModeFor, modesFor, runPostureOf, withTimeout } from "./role-mode";
34
+ import { workspaceDirectory, type DashboardPaseo } from "./dashboard-rpc";
35
+ import { readIncidents } from "./fallback-state";
36
+ import { installHomeOf } from "./role-extras";
37
+ import { recordTools } from "./tools-check";
38
+
39
+ /** Label key and value that identify a paseo-bm Manager (design §5). */
40
+ export const MANAGER_ROLE_LABEL = "bm.role";
41
+ export const MANAGER_ROLE_VALUE = "manager";
42
+ export const VERSION_LABEL = "bm.version";
43
+
44
+ /**
45
+ * "paseo-bm already set this Manager's mode", with the mode as its value
46
+ * (delta 20260918 §4.1). A Manager that carries it is never switched again, so
47
+ * a mode the user picks by hand afterwards is respected (owner decision Q36).
48
+ */
49
+ export const MODE_SET_LABEL = "bm.modeSet";
50
+
51
+ /** Label on an agent another agent replaced, with the replacement's id (delta 20260921 §4.4.8, §4.5.2). */
52
+ const REPLACED_BY_LABEL = "bm.replacedBy";
53
+
54
+ /** Agent profile id registered by the installer (ADR-006, WP-107). */
55
+ export const MANAGER_PROFILE_ID = "bm-manager";
56
+
57
+ /** Title given to a freshly created Manager. */
58
+ export const MANAGER_TITLE = "Beads Manager";
59
+
60
+ /**
61
+ * Error codes this RPC can report. Taken from the single Phase 1 registry in
62
+ * design §4.4 ("Không được đặt mã tại chỗ"); §5 itself lists no RPC codes.
63
+ */
64
+ export type ManagerEnsureErrorCode = "E_PROVIDER_UNAVAILABLE";
65
+
66
+ /**
67
+ * Coded failure of `manager.ensure`. The message starts with the code so it
68
+ * survives transports that only forward `message`.
69
+ */
70
+ export class ManagerEnsureError extends Error {
71
+ readonly code: ManagerEnsureErrorCode;
72
+
73
+ constructor(code: ManagerEnsureErrorCode, detail: string, options?: { cause?: unknown }) {
74
+ super(`${code}: ${detail}`, options);
75
+ this.name = "ManagerEnsureError";
76
+ this.code = code;
77
+ }
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Minimal, injectable view of the Paseo SDK. `PaseoApi` from @getpaseo/client
82
+ // is structurally assignable to it (index.server.ts passes the real one, which
83
+ // `npm run typecheck:plugin` checks); tests pass a fake.
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export interface ManagerAgentSnapshot {
87
+ id: string;
88
+ workspaceId?: string;
89
+ createdAt: string;
90
+ status: string;
91
+ labels: Record<string, string>;
92
+ /** Provider selection (`bm-manager` or `bm-manager/<model>`); decides the role when the label is missing. */
93
+ provider?: string;
94
+ archivedAt?: string | null;
95
+ providerUnavailable?: boolean;
96
+ lastError?: string;
97
+ /** The mode the agent is in now; `agents.list` entries carry it. */
98
+ currentModeId?: string | null;
99
+ /** The agent's feature values, when the snapshot carries them (`auto_accept` on OpenCode). */
100
+ features?: ReadonlyArray<{ id?: string; value?: unknown }>;
101
+ /** What the agent's provider supports; `supportsMcpServers: false` means no Paseo tools (delta 20260921 §4.2.4). */
102
+ capabilities?: { supportsMcpServers?: unknown } | null;
103
+ }
104
+
105
+ export interface ManagerAgentHandle {
106
+ readonly id: string;
107
+ current(): ManagerAgentSnapshot | null;
108
+ archive(): Promise<unknown>;
109
+ }
110
+
111
+ export interface ManagerAgentProfile {
112
+ id: string;
113
+ provider: string;
114
+ model?: string;
115
+ modeId?: string;
116
+ thinkingOptionId?: string;
117
+ featureValues?: Record<string, unknown>;
118
+ }
119
+
120
+ export interface ManagerPaseo {
121
+ agents: {
122
+ list(options: {
123
+ filter: { labels?: Record<string, string>; includeArchived: boolean };
124
+ page: { limit: number; cursor?: string };
125
+ }): Promise<{
126
+ entries: Array<{ agent: ManagerAgentSnapshot }>;
127
+ pageInfo: { nextCursor: string | null; hasMore: boolean };
128
+ }>;
129
+ };
130
+ workspaces: {
131
+ ref(workspaceId: string): {
132
+ agents: {
133
+ create(options: {
134
+ config: {
135
+ provider: string;
136
+ modeId?: string;
137
+ thinkingOptionId?: string;
138
+ featureValues?: Record<string, unknown>;
139
+ systemPrompt?: string;
140
+ };
141
+ title?: string;
142
+ labels?: Record<string, string>;
143
+ /** The agent's first message (a replacement Manager's `BM-HANDOVER`, delta 20260921 §4.5.2). */
144
+ prompt?: string;
145
+ }): Promise<ManagerAgentHandle>;
146
+ };
147
+ };
148
+ };
149
+ config: {
150
+ get(): Promise<{ config: { agentProfiles?: ManagerAgentProfile[] } }>;
151
+ };
152
+ /** Absent on a host that cannot list modes; the Manager then gets no mode of ours. */
153
+ providers?: {
154
+ listModes(provider: string, options?: { cwd?: string }): Promise<unknown>;
155
+ };
156
+ }
157
+
158
+ export interface EnsureManagerDeps {
159
+ paseo: ManagerPaseo;
160
+ /** Returns the text of `roles/manager.md`. */
161
+ readInstructions: () => Promise<string>;
162
+ /** Plugin version written to `bm.version`. Defaults to the baked-in version. */
163
+ version?: string;
164
+ /** Where a failed mode lookup is reported. Defaults to `console.warn`. */
165
+ log?: (message: string) => void;
166
+ /** How the `paseo` CLI is found and run; tests pass a fake runner. */
167
+ cli?: PaseoCliDeps;
168
+ /**
169
+ * The workspace's directory, needed to read an untiered provider's features
170
+ * (delta 20260921 §4.2.2). Defaults to the directory Paseo lists for it.
171
+ */
172
+ workspaceDirectory?: (workspaceId: string) => Promise<string | null>;
173
+ /**
174
+ * The install home, whose `role-fallback-state.json` names the replaced
175
+ * Managers (delta 20260921 §4.5.2); `null` means none. Looked up within the
176
+ * lookup budget when absent; tests pass one.
177
+ */
178
+ home?: string | null;
179
+ }
180
+
181
+ export interface EnsureManagerResult {
182
+ agentId: string;
183
+ created: boolean;
184
+ /**
185
+ * Other live Managers found in the same workspace, newest first, never a
186
+ * replaced one. Reported so the panel can tell the user (design §9.3); never
187
+ * deleted or archived here.
188
+ */
189
+ otherManagerIds: string[];
190
+ /**
191
+ * Why an existing Manager was not switched to its mode, or `null` when there
192
+ * was nothing to say (delta 20260918 §4.1). Never blocks returning the Manager.
193
+ */
194
+ modeNotice: string | null;
195
+ /**
196
+ * Set when the Manager just created has no Paseo tools (Pi without
197
+ * pi-mcp-adapter), so it cannot create or message a Worker (delta 20260921
198
+ * §4.2.4); `null` otherwise, and always for an existing Manager.
199
+ */
200
+ toolsNotice: string | null;
201
+ }
202
+
203
+ /** A Manager is live when it is neither archived nor closed. */
204
+ function isLive(agent: ManagerAgentSnapshot): boolean {
205
+ return !agent.archivedAt && agent.status !== "closed";
206
+ }
207
+
208
+ /** Newest first by `createdAt`; ties broken by id so the choice is stable. */
209
+ function newestFirst(a: ManagerAgentSnapshot, b: ManagerAgentSnapshot): number {
210
+ const byTime = Date.parse(b.createdAt) - Date.parse(a.createdAt);
211
+ if (byTime !== 0 && !Number.isNaN(byTime)) return byTime;
212
+ return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
213
+ }
214
+
215
+ /** True when the Manager carries the `bm.role=manager` label; false when only its provider says so. */
216
+ function labelledManager(agent: ManagerAgentSnapshot): boolean {
217
+ return roleOfAgent(agent)?.labelled === true;
218
+ }
219
+
220
+ /**
221
+ * Every live Manager of the workspace: labelled Managers first, then the ones
222
+ * recognised only by their `bm-manager` provider (started from Paseo's own
223
+ * new-agent flow), each group newest first (delta 20260918g §4.3). Listing
224
+ * with no label filter is what lets the second group be found at all; without
225
+ * it `manager.ensure` created a second Manager next to the user's own.
226
+ *
227
+ * A replaced Manager is left out (delta 20260921 §4.5.2): it carries
228
+ * `bm.replacedBy`, or — when labelling it failed — it is the `agentId` of a
229
+ * `switched` Manager incident. So `manager.ensure` opens the replacement and
230
+ * never reports the old one, which stays alive until the user archives it
231
+ * (ADR-005). The incidents are only read when a live Manager is found.
232
+ */
233
+ export async function findLiveManagers(
234
+ paseo: ManagerPaseo,
235
+ workspaceId: string,
236
+ lookup: { home?: string | null; log?: (message: string) => void } = {},
237
+ ): Promise<ManagerAgentSnapshot[]> {
238
+ const all = await listAllAgents((options) => paseo.agents.list(options), { includeArchived: false });
239
+ const live = all.filter(
240
+ (agent) =>
241
+ agent.workspaceId === workspaceId &&
242
+ roleOfAgent(agent)?.role === "manager" &&
243
+ isLive(agent) &&
244
+ agent.labels?.[REPLACED_BY_LABEL] === undefined,
245
+ );
246
+ const replaced = live.length === 0 ? new Set<string>() : await replacedManagerIds(paseo, lookup);
247
+ return live
248
+ .filter((agent) => !replaced.has(agent.id))
249
+ .sort((a, b) => Number(labelledManager(b)) - Number(labelledManager(a)) || newestFirst(a, b));
250
+ }
251
+
252
+ /**
253
+ * The `agentId` of every `switched` Manager incident in the install home's
254
+ * `role-fallback-state.json` (delta 20260921 §4.5.2). Empty when the home is
255
+ * not found within the lookup budget or the file cannot be used; the
256
+ * `bm.replacedBy` label still applies then. Never throws.
257
+ */
258
+ async function replacedManagerIds(
259
+ paseo: ManagerPaseo,
260
+ lookup: { home?: string | null; log?: (message: string) => void },
261
+ ): Promise<Set<string>> {
262
+ try {
263
+ const home = lookup.home !== undefined ? lookup.home : await withTimeout(installHomeOf(paseo));
264
+ if (home === null || home === TIMED_OUT) return new Set();
265
+ return new Set(
266
+ readIncidents(home, lookup.log)
267
+ .incidents.filter((incident) => incident.role === "manager" && incident.status === "switched")
268
+ .map((incident) => incident.agentId),
269
+ );
270
+ } catch {
271
+ return new Set();
272
+ }
273
+ }
274
+
275
+ /** The workspace's directory for a features lookup, or `null`. Never throws. */
276
+ async function directoryOf(deps: EnsureManagerDeps, workspaceId: string): Promise<string | null> {
277
+ try {
278
+ return deps.workspaceDirectory !== undefined
279
+ ? await deps.workspaceDirectory(workspaceId)
280
+ : await workspaceDirectory(deps.paseo as unknown as DashboardPaseo, workspaceId);
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ function providerSelection(profile: ManagerAgentProfile): string {
287
+ return profile.model ? `${profile.provider}/${profile.model}` : profile.provider;
288
+ }
289
+
290
+ /** The snapshot of a just-created agent says its provider could not start. */
291
+ function startedBroken(snapshot: ManagerAgentSnapshot | null): boolean {
292
+ return snapshot !== null && (snapshot.status === "error" || snapshot.providerUnavailable === true);
293
+ }
294
+
295
+ /**
296
+ * Handler body of `manager.ensure`.
297
+ *
298
+ * Throws `ManagerEnsureError` (`E_PROVIDER_UNAVAILABLE`) when the Manager cannot
299
+ * be created: the `bm-manager` profile is missing, the SDK rejects the create,
300
+ * or the created agent comes back already failed (then that exact agent is
301
+ * archived before throwing).
302
+ */
303
+ export async function ensureManager(
304
+ input: { workspaceId: string },
305
+ deps: EnsureManagerDeps,
306
+ ): Promise<EnsureManagerResult> {
307
+ const { paseo } = deps;
308
+ const { workspaceId } = input;
309
+
310
+ const [chosen, ...others] = await findLiveManagers(paseo, workspaceId, { home: deps.home, log: deps.log });
311
+ if (chosen) {
312
+ return {
313
+ agentId: chosen.id,
314
+ created: false,
315
+ otherManagerIds: others.map((agent) => agent.id),
316
+ // A Manager recognised only by its provider was created by the user in a
317
+ // mode they chose: paseo-bm never switches it (delta 20260918g §4.3).
318
+ modeNotice: labelledManager(chosen) ? await switchOnce(chosen, deps) : null,
319
+ toolsNotice: null,
320
+ };
321
+ }
322
+
323
+ const { config } = await paseo.config.get();
324
+ const profile = config.agentProfiles?.find((entry) => entry.id === MANAGER_PROFILE_ID);
325
+ if (!profile) {
326
+ throw new ManagerEnsureError(
327
+ "E_PROVIDER_UNAVAILABLE",
328
+ `agent profile "${MANAGER_PROFILE_ID}" is not registered on this daemon; re-run \`npx paseo-bm\` to register the roles.`,
329
+ );
330
+ }
331
+
332
+ // The provider's no-prompt mode unless the profile names its own (delta
333
+ // 20260918 §4.1). `modesFor` is raced against 5 s and logs every miss; a miss
334
+ // creates the Manager exactly as before, without the label, so the next open
335
+ // can still switch it.
336
+ const provider = providerId(profile.provider) ?? profile.provider;
337
+ const modes = await modesFor(paseo, provider, deps.log);
338
+ const capability = capabilityOf(modes);
339
+ let chosenMode: string | undefined;
340
+ let modeId: string | undefined;
341
+ let featureValues = profile.featureValues;
342
+ if (capability === "untiered" || capability === "none") {
343
+ // Delta 20260921 §4.2.2: OpenCode (untiered) gets a listed mode — the
344
+ // profile's, else the first — and auto-approve on unless the profile sets
345
+ // it; Pi (none) gets neither.
346
+ const features =
347
+ capability === "untiered"
348
+ ? await featuresFor(paseo, providerSelection(profile), (await directoryOf(deps, workspaceId)) ?? undefined, deps.log)
349
+ : null;
350
+ const posture = runPostureOf("manager", capability, modes ?? [], features, profile.modeId ?? null, {
351
+ ...(profile.featureValues !== undefined ? { featureValues: profile.featureValues } : {}),
352
+ });
353
+ chosenMode = posture?.modeId ?? undefined;
354
+ modeId = chosenMode;
355
+ // `null` removes the key: a Manager on a provider without modes (Pi) gets no
356
+ // mode and no feature, even when its profile sets them (review b4).
357
+ if (posture?.featureValues === null) featureValues = undefined;
358
+ else if (posture?.featureValues !== undefined) featureValues = posture.featureValues;
359
+ } else {
360
+ chosenMode = modes === null ? undefined : managerModeFor(modes, profile.modeId ?? null);
361
+ // Modes unknown: exactly as before this delta. Modes known: only a mode the
362
+ // provider lists — a profile mode it does not list would make the creation fail.
363
+ modeId = modes === null ? profile.modeId : chosenMode;
364
+ }
365
+ if (capability === "tiered" && chosenMode === undefined) {
366
+ (deps.log ?? ((message: string) => console.warn(message)))(
367
+ `[paseo-bm] ${provider} lists no mode that runs without permission prompts${
368
+ profile.modeId === undefined ? "" : `, nor the profile's own mode "${profile.modeId}"`
369
+ }; the Manager starts in the provider's default mode.`,
370
+ );
371
+ }
372
+
373
+ const created = await createManager(paseo, workspaceId, {
374
+ providerSelection: providerSelection(profile),
375
+ modeId,
376
+ thinkingOptionId: profile.thinkingOptionId,
377
+ featureValues,
378
+ labels: chosenMode !== undefined ? { [MODE_SET_LABEL]: chosenMode } : {},
379
+ readInstructions: deps.readInstructions,
380
+ version: deps.version,
381
+ });
382
+ return { agentId: created.agentId, created: true, otherManagerIds: [], modeNotice: null, toolsNotice: created.toolsNotice };
383
+ }
384
+
385
+ /** What `createManager` creates; the caller has already decided the provider, mode and thinking. */
386
+ export interface CreateManagerOptions extends Pick<EnsureManagerDeps, "readInstructions" | "version"> {
387
+ /** `provider/model`: `bm-manager/<model>` from the profile, or `bm-manager-fallback-<n>/<model>` for a replacement. */
388
+ providerSelection: string;
389
+ modeId?: string;
390
+ thinkingOptionId?: string;
391
+ featureValues?: Record<string, unknown>;
392
+ /**
393
+ * Added to `bm.role=manager` and `bm.version`: `bm.modeSet` when paseo-bm
394
+ * chose the mode (not for a profile mode passed on unchecked), `bm.replaces`
395
+ * for a replacement (delta 20260921 §4.5.2).
396
+ */
397
+ labels: Record<string, string>;
398
+ /** The Manager's first message: a replacement's `BM-HANDOVER`. None when absent. */
399
+ prompt?: string;
400
+ }
401
+
402
+ export interface CreateManagerResult {
403
+ agentId: string;
404
+ /** As `EnsureManagerResult.toolsNotice`. */
405
+ toolsNotice: string | null;
406
+ }
407
+
408
+ /**
409
+ * Creates a Manager in the workspace: the one code path for `manager.ensure`
410
+ * and for a replacement Manager (delta 20260921 §4.5.2), so both get the same
411
+ * instructions and Runtime facts (`readInstructions`), the same labels and the
412
+ * same `toolsNotice`.
413
+ *
414
+ * Throws `ManagerEnsureError` (`E_PROVIDER_UNAVAILABLE`) when the SDK rejects
415
+ * the create, or when the created agent comes back already failed (then that
416
+ * exact agent is archived before throwing).
417
+ */
418
+ export async function createManager(
419
+ paseo: ManagerPaseo,
420
+ workspaceId: string,
421
+ options: CreateManagerOptions,
422
+ ): Promise<CreateManagerResult> {
423
+ const { providerSelection: selection, modeId, thinkingOptionId, featureValues, prompt } = options;
424
+ const systemPrompt = await options.readInstructions();
425
+ // `manager.ensure` names the profile, as it always has; a replacement names its provider.
426
+ const alias = providerId(selection) ?? selection;
427
+ const source = alias === MANAGER_PROFILE_ID ? `profile "${MANAGER_PROFILE_ID}"` : `provider "${selection}"`;
428
+
429
+ let handle: ManagerAgentHandle;
430
+ try {
431
+ handle = await paseo.workspaces.ref(workspaceId).agents.create({
432
+ config: {
433
+ provider: selection,
434
+ ...(modeId !== undefined ? { modeId } : {}),
435
+ ...(thinkingOptionId !== undefined ? { thinkingOptionId } : {}),
436
+ ...(featureValues !== undefined ? { featureValues } : {}),
437
+ systemPrompt,
438
+ },
439
+ title: MANAGER_TITLE,
440
+ labels: {
441
+ [MANAGER_ROLE_LABEL]: MANAGER_ROLE_VALUE,
442
+ [VERSION_LABEL]: options.version ?? PLUGIN_VERSION,
443
+ ...options.labels,
444
+ },
445
+ ...(prompt !== undefined ? { prompt } : {}),
446
+ });
447
+ } catch (cause) {
448
+ throw new ManagerEnsureError(
449
+ "E_PROVIDER_UNAVAILABLE",
450
+ `could not create the Manager with ${source}: ${cause instanceof Error ? cause.message : String(cause)}`,
451
+ { cause },
452
+ );
453
+ }
454
+
455
+ const snapshot = handle.current();
456
+ if (startedBroken(snapshot)) {
457
+ // Clean up exactly the agent this call created; nothing else is touched.
458
+ let cleanup = "it was archived";
459
+ try {
460
+ await handle.archive();
461
+ } catch (archiveError) {
462
+ cleanup = `archiving it failed (${archiveError instanceof Error ? archiveError.message : String(archiveError)}); archive agent ${handle.id} yourself`;
463
+ }
464
+ throw new ManagerEnsureError(
465
+ "E_PROVIDER_UNAVAILABLE",
466
+ `the Manager's provider did not start${snapshot?.lastError ? `: ${snapshot.lastError}` : ""}; ${cleanup}.`,
467
+ );
468
+ }
469
+
470
+ // Delta 20260921 §4.2.4: a Manager without Paseo tools cannot run a request.
471
+ const supports = snapshot?.capabilities?.supportsMcpServers;
472
+ recordTools("manager", handle.id, selection, supports);
473
+ const toolsNotice =
474
+ supports === false
475
+ ? `This Manager runs on ${selection} without Paseo tools (on Pi this means pi-mcp-adapter is missing): it cannot create or message a Worker.`
476
+ : null;
477
+ return { agentId: handle.id, toolsNotice };
478
+ }
479
+
480
+ /**
481
+ * Switches a Manager created before delta 20260918 to its mode, once, and marks
482
+ * it with `bm.modeSet` (§4.1, owner decisions Q36, Q37, Q39). Returns what the
483
+ * user should be told, or `null`. Never throws: whatever goes wrong, the
484
+ * Manager is still opened.
485
+ */
486
+ async function switchOnce(manager: ManagerAgentSnapshot, deps: EnsureManagerDeps): Promise<string | null> {
487
+ try {
488
+ // 1. Already handled: a mode the user picked since then is theirs.
489
+ if (manager.labels[MODE_SET_LABEL] !== undefined) return null;
490
+
491
+ // 2. The same target a new Manager would get; no target, nothing to do.
492
+ const { config } = await deps.paseo.config.get();
493
+ const profile = config.agentProfiles?.find((entry) => entry.id === MANAGER_PROFILE_ID);
494
+ if (!profile) return null;
495
+ const provider = providerId(profile.provider) ?? profile.provider;
496
+ const modes = await modesFor(deps.paseo, provider, deps.log);
497
+ if (capabilityOf(modes) === "untiered") {
498
+ // The plugin cannot change the features of an existing agent (proposal
499
+ // S9), so auto-approve cannot be turned on here (delta 20260921 §4.2.2).
500
+ const autoApprove = manager.features?.find((feature) => feature?.id === AUTO_APPROVE_FEATURE)?.value === true;
501
+ return autoApprove
502
+ ? null
503
+ : `This Manager runs on ${provider} and was created before paseo-bm could turn on auto-approve for it; it may ask for permissions. Start a new Manager to run without prompts.`;
504
+ }
505
+ const target = modes === null ? undefined : managerModeFor(modes, profile.modeId ?? null);
506
+ if (target === undefined) return null;
507
+
508
+ // 3. Switching mid-turn can rebuild a Claude session's query: wait for the next open.
509
+ if (manager.status === "running") {
510
+ return `Beads Manager ${manager.id} is busy, so it was not switched to "${target}" yet; paseo-bm will try again the next time you open it.`;
511
+ }
512
+
513
+ // 4. Switch only when it is not already there.
514
+ if (manager.currentModeId !== target) {
515
+ const switched = await setAgentMode(manager.id, target, deps.cli);
516
+ if (!switched.ok) {
517
+ return `Could not switch Beads Manager ${manager.id} to "${target}": ${switched.reason}. Switch its mode yourself in Paseo.`;
518
+ }
519
+ }
520
+
521
+ // 5. Mark it, so it is never switched again. A miss here only means the next open marks it.
522
+ const marked = await setAgentLabel(manager.id, MODE_SET_LABEL, target, deps.cli);
523
+ if (!marked.ok) {
524
+ return `Beads Manager ${manager.id} is in "${target}", but could not be marked as done (${marked.reason}); paseo-bm will try again the next time you open it.`;
525
+ }
526
+ return null;
527
+ } catch (error) {
528
+ return `Could not check the mode of Beads Manager ${manager.id}: ${error instanceof Error ? error.message : String(error)}. Switch its mode yourself in Paseo if it still asks for permission.`;
529
+ }
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // `agents.list` (WP-112, design §5, REQ-025a)
534
+ // ---------------------------------------------------------------------------
535
+
536
+ /** Label Paseo sets on an agent created by another agent (AGENTS.md, verified). */
537
+ export const PARENT_AGENT_LABEL = "paseo.parent-agent-id";
538
+
539
+ /** Snapshot fields `agents.list` reads. `PaseoAgent` is structurally assignable. */
540
+ export interface ListedAgentSnapshot {
541
+ id: string;
542
+ workspaceId?: string;
543
+ title: string | null;
544
+ status: string;
545
+ createdAt: string;
546
+ updatedAt: string;
547
+ labels: Record<string, string>;
548
+ /** Provider selection; decides the role when the `bm.role` label is missing. */
549
+ provider?: string;
550
+ archivedAt?: string | null;
551
+ }
552
+
553
+ /**
554
+ * Minimal SDK view for `agents.list`. The directory filter's `labels` is
555
+ * optional in the protocol, and it must be omitted here: an unlabeled agent can
556
+ * only be recognised through its parent.
557
+ */
558
+ export interface AgentDirectoryPaseo {
559
+ agents: {
560
+ list(options: {
561
+ filter: { includeArchived: boolean };
562
+ page: { limit: number; cursor?: string };
563
+ }): Promise<{
564
+ entries: Array<{ agent: ListedAgentSnapshot }>;
565
+ pageInfo: { nextCursor: string | null; hasMore: boolean };
566
+ }>;
567
+ };
568
+ }
569
+
570
+ /** The label's role, else the provider's (delta 20260918g §4.1), else `unknown`. */
571
+ function roleOf(agent: ListedAgentSnapshot): AgentNode["role"] {
572
+ return roleOfAgent(agent)?.role ?? "unknown";
573
+ }
574
+
575
+ /** Oldest first by `createdAt`, ties by id, so the list order is stable. */
576
+ function oldestFirst(a: ListedAgentSnapshot, b: ListedAgentSnapshot): number {
577
+ const byTime = Date.parse(a.createdAt) - Date.parse(b.createdAt);
578
+ if (byTime !== 0 && !Number.isNaN(byTime)) return byTime;
579
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
580
+ }
581
+
582
+ /**
583
+ * Handler body of `agents.list`: the non-archived paseo-bm agents of one
584
+ * workspace, flat, oldest first.
585
+ *
586
+ * Membership: an agent with a paseo-bm role — by its `bm.role` label or, when
587
+ * that is missing, by its `bm-*` provider (delta 20260918g) — plus every agent
588
+ * that descends from one through `paseo.parent-agent-id` (an agent of another
589
+ * provider under a Worker still shows up, with role `unknown`). A node without a
590
+ * valid label is `labelled: false`; nothing here throws on labels.
591
+ *
592
+ * `parentId` is the parent label only when that parent is in the result. A
593
+ * parent that was deleted or archived yields `null`, so an orphaned Worker is a
594
+ * root the client can still draw. Read-only: nothing is stopped or archived.
595
+ */
596
+ export async function listWorkspaceAgents(
597
+ input: { workspaceId: string },
598
+ deps: {
599
+ paseo: AgentDirectoryPaseo;
600
+ /** Agent id → its replacement, from `switched` fallback incidents (delta 20260921 §4.4.8). */
601
+ replacements?: ReadonlyMap<string, string>;
602
+ },
603
+ ): Promise<{ agents: AgentNode[] }> {
604
+ const all = await listAllAgents((options) => deps.paseo.agents.list(options), {
605
+ includeArchived: false,
606
+ });
607
+ const inWorkspace = all.filter(
608
+ (agent) => agent.workspaceId === input.workspaceId && !agent.archivedAt,
609
+ );
610
+
611
+ // Roots: every agent with a paseo-bm role, by label or by provider, and — as
612
+ // before delta 20260918g — any agent carrying a `bm.role` label at all.
613
+ const members = new Set(
614
+ inWorkspace
615
+ .filter((agent) => roleOfAgent(agent) !== null || agent.labels[MANAGER_ROLE_LABEL] !== undefined)
616
+ .map((a) => a.id),
617
+ );
618
+ // Pull in descendants until the set stops growing (depth is small; bounded by n passes).
619
+ for (let grew = true; grew; ) {
620
+ grew = false;
621
+ for (const agent of inWorkspace) {
622
+ const parent = agent.labels[PARENT_AGENT_LABEL];
623
+ if (!members.has(agent.id) && parent !== undefined && members.has(parent)) {
624
+ members.add(agent.id);
625
+ grew = true;
626
+ }
627
+ }
628
+ }
629
+
630
+ const agents = inWorkspace
631
+ .filter((agent) => members.has(agent.id))
632
+ .sort(oldestFirst)
633
+ .map((agent): AgentNode => {
634
+ const parent = agent.labels[PARENT_AGENT_LABEL];
635
+ return {
636
+ id: agent.id,
637
+ role: roleOf(agent),
638
+ title: agent.title,
639
+ status: agent.status,
640
+ parentId: parent !== undefined && members.has(parent) ? parent : null,
641
+ updatedAt: agent.updatedAt,
642
+ labelled: roleOfAgent(agent)?.labelled ?? false,
643
+ replacedBy: agent.labels["bm.replacedBy"] ?? deps.replacements?.get(agent.id) ?? null,
644
+ };
645
+ });
646
+ return { agents };
647
+ }