aloita-extensions 0.4.7 → 0.4.8

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.
package/README.md CHANGED
@@ -1,413 +1,57 @@
1
1
  # aloita-extensions
2
2
 
3
- Pi extensions that **replace both** the [Aura SignalR receiver](https://github.com/frankvl76/aura-signalr-client) and the [Aura MCP server](https://github.com/frankvl76/aura-mcp-server) with a single Pi extension.
3
+ A Pi extension. One long-running Pi session connects to your Aloita server and works on tickets natively no separate MCP server or subprocesses.
4
4
 
5
- Instead of spawning `claude -p` / `opencode` per ticket and talking to Aloita over MCP stdio, **one Pi session connects to Aloita over SignalR and works on tickets natively.** The 28 `aura_*` tools are registered as first-class Pi tools — no MCP, no subprocess, no separate FastAPI dashboard.
5
+ ## Install
6
6
 
7
- ```
8
- ┌─────────────────────────────────────────────┐
9
- │ ONE Pi session (this extension loaded) │
10
- │ │
11
- Aloita ──SignalR──► onWebhookEvent │
12
- board │ ├─ dedup → resolve folder │
13
- (formerly │ ├─ set "In Progress" + agent-task │
14
- "Aura") │ └─ pi.sendUserMessage(prompt) ──┐ │
15
- ◄─REST───│◄── aura_* tools (28) ◄─────────────── the │
16
- │ (get ticket, comment, checklist, …) agent │
17
- │ │
18
- │ on agent_end → "Done" + drain next queued │
19
- └─────────────────────────────────────────────┘
20
- ```
21
-
22
- ## What this replaces
23
-
24
- | Old (Python) | New (this extension) |
25
- |---------------------------------------|--------------------------------------------------------|
26
- | `signalr_receiver.py` (2848 lines) | `src/signalr.ts` + `src/index.ts` webhook handling |
27
- | `signalr_loop_bridge.py` + supervisor | gone — the Pi session **is** the long-lived consumer |
28
- | FastAPI dashboard + `static/` | `ctx.ui.setStatus()` footer + `notify()` + `/aloita-*` |
29
- | `aura-mcp-server/server.py` (28 tools)| `src/tools.ts` — same 28 tools, registered natively |
30
- | `aura_client.py` (httpx) | `src/aura-client.ts` (`fetch`) |
31
- | Subprocess spawn + `.cmd` shims | gone — no subprocesses |
32
- | Usage reconstruction from transcripts | `message_end` usage → `/agent-run-usage` on `agent_end` |
33
- | `AuraAgentLogPusher` + stdout line reader | `src/agent-log.ts` — fed from Pi streaming events (see §Agent log streaming) |
34
- | `--resume <sessionID>` dance | gone — the session is already long-lived |
35
-
36
- ## Quick start
37
-
38
- **Prerequisites:** Pi (`@earendil-works/pi-coding-agent`) on your PATH and Node 18+.
39
-
40
- ### 1. Install the extension (one command)
7
+ Requires Pi (`@earendil-works/pi-coding-agent`) on your PATH and Node 18+.
41
8
 
42
- ```powershell
9
+ ```sh
43
10
  pi install npm:aloita-extensions
44
11
  ```
45
12
 
46
- That's it. `pi install` writes the package to Pi's global settings (`~/.pi/agent/settings.json` → `packages: [...]`) and fetches its `dependencies` (`ws`). Plain `pi` (no flags) now auto-loads the extension on **every startup** no manual path configuration, no `--extension` flag, no rebuild step.
47
-
48
- Pi bundles the `@earendil-works/*` and `typebox` packages this extension imports (declared as `peerDependencies`), so they are provided by Pi at runtime.
49
-
50
- > **Offline / air-gapped install:** if the npm tarball (`.tgz`) is available locally instead of published to a registry, install directly from it: `pi install ./aloita-extensions-0.2.0.tgz`. See [§Distributable](#distributable) for details.
51
-
52
- Manage the registration:
53
-
54
- ```powershell
55
- pi list # confirm it's registered
56
- pi remove npm:aloita-extensions # unregister
57
- pi update --extensions # update all installed packages
58
- ```
59
-
60
- ### 2. Configure Aloita access
61
-
62
- Set your Aloita server URL + API key (env vars are the simplest — good for secrets):
63
-
64
- ```powershell
65
- # PowerShell (current session):
66
- $env:ALOITA_URL = "https://your-aloita-server"
67
- $env:ALOITA_API_KEY = "kai_your_key_here"
68
-
69
- # Or persist as user environment variables (run once):
70
- setx ALOITA_URL "https://your-aloita-server"
71
- setx ALOITA_API_KEY "kai_your_key_here"
72
- ```
73
-
74
- Alternatively, copy `config.example.json` to one of these paths and edit (full schema in [§Configure](#configure)):
75
- - `~/.pi/aloita.json` — global (**recommended** for `repos`, capabilities, and auto-start settings)
76
- - `<cwd>/.pi/aloita.json` — project-local (preferred for per-project overrides)
77
-
78
- **Or skip the env vars entirely** — run `/aloita-configure` inside Pi and set the server URL + API key in the **Connection settings** screen. They persist to `~/.pi/aloita.json` (global) so every Pi session on this machine uses them.
79
-
80
- Launch `pi`. The footer shows `● Aloita: connected` when the SignalR socket is up.
81
-
82
- ### 3. (Optional) Auto-start + `/aloita-configure`
13
+ Pi auto-loads the extension on every startup. Manage it with:
83
14
 
84
- To let this Pi session automatically start a dedicated child `pi` in specific project folders, open the interactive settings UI:
85
-
86
- ```
87
- /aloita-configure
88
- ```
89
-
90
- Toggle which projects are in the auto-start allow-list, adjust options, then **Save & close** — changes persist to the config file and hot-reload immediately. See [§Auto-start supervisor](#auto-start-supervisor) and [§`/aloita-configure`](#aloita-configure--interactive-settings-ui) for detail. (Auto-start is opt-in and off by default.)
91
-
92
- ## Distributable
93
-
94
- The extension ships as an npm tarball (`npm pack` output): `aloita-extensions-0.2.0.tgz`. This is the single artifact for distribution — it contains exactly the package contents (source, docs, README, LICENSE, config example, tsconfig) and nothing else (no `node_modules`, no `.git`, no test artifacts).
95
-
96
- Two install paths from the tarball:
97
-
98
- 1. **Publish to npm**, then install by name (recommended for repeated installs):
99
- ```powershell
100
- npm publish aloita-extensions-0.2.0.tgz # one-time publish
101
- pi install npm:aloita-extensions # install on any machine
102
- ```
103
-
104
- 2. **Install directly from the tarball** (no registry needed — good for air-gapped / private setups):
105
- ```powershell
106
- pi install ./aloita-extensions-0.2.0.tgz
107
- ```
108
- This registers the tarball path in Pi's settings and auto-loads the extension on every startup, just like the npm path.
109
-
110
- A `distributable.zip` is attached to the packaging ticket — it contains the `.tgz` plus this README, so a developer has everything needed in one download.
111
-
112
- ---
113
-
114
- ## Install (local development)
115
-
116
- For hacking on this extension from a clone:
117
-
118
- ```powershell
119
- git clone https://github.com/frankvl76/aloita-extensions
120
- cd aloita-extensions
121
- npm install # fetches ws + dev/type deps
122
- npm run build # tsc --noEmit type-check
123
- npm test # node --test src/*.test.ts
124
- ```
125
-
126
- Register the local folder so it auto-loads (reads the `pi.extensions` manifest from `package.json`):
127
-
128
- ```powershell
129
- # Global — loads in EVERY project folder (matches the one-Pi-per-folder model).
130
- pi install C:\path\to\aloita-extensions
131
-
132
- # Project-local — only the current folder:
133
- pi install -l C:\path\to\aloita-extensions
134
- ```
135
-
136
- `pi install` stores a path relative to `~/.pi/agent`; `pi list` prints the resolved absolute path so you can sanity-check it. Global packages are loaded regardless of project trust, so they apply in every cwd.
137
-
138
- Because Pi loads extensions via [jiti](https://github.com/unjs/jiti) on each launch, **source edits are picked up immediately on the next `pi` start** and `/reload` hot-reloads them in a running session — no rebuild step.
139
-
140
- ### Manual / one-off (no registration)
141
-
142
- For a single run with no registration, use the explicit flag:
143
-
144
- ```powershell
145
- pi --extension C:\path\to\aloita-extensions\src\index.ts
15
+ ```sh
16
+ pi list # confirm it's registered
17
+ pi remove npm:aloita-extensions # unregister
18
+ pi update --extensions # update installed packages
146
19
  ```
147
20
 
148
21
  ## Configure
149
22
 
150
- Two sources, first non-empty wins:
151
-
152
- 1. **Env vars** (good for secrets): `ALOITA_URL`, `ALOITA_API_KEY` (also `AURA_REPOS`).
153
- 2. **JSON file** discovered at one of:
154
- - `<extensionDir>/aloita.json` — sibling of `package.json`
155
- - `<cwd>/.pi/aloita.json` — project-local (**preferred**, per-folder)
156
- - `~/.pi/aloita.json` — global
157
-
158
- Copy `config.example.json` to one of those paths and edit. Full schema:
159
-
160
- | Field | Default | Purpose |
161
- |------------------------|---------|-------------------------------------------------------------------------|
162
- | `aloitaUrl` | — | Base URL of the Aloita server (env `ALOITA_URL`). |
163
- | `apiKey` | — | `kai_…` key (env `ALOITA_API_KEY`). |
164
- | `subscription` | see ex | `{ events, allProjects, allUsers, projectIds, userIds }` bitmask filter.|
165
- | `projectFolders` | `{}` | `projectId → local path` override (wins over Aloita-side `LocalFolder`). |
166
- | `projectFilter` | `""` | If set, this session only claims tickets for that project id. |
167
- | `matchAllProjects` | `false` | If true, claim every ticket regardless of folder (uses this cwd). |
168
- | `repos` | `""` | `${REPOS}` expansion target (env `AURA_REPOS`). |
169
- | `labelToCommand` | see ex | Ticket label → command template. |
170
- | `defaultCommand` | `aura-work-on-ticket` | Fallback command. |
171
- | `guardStatus` | `true` | Block the agent from changing ticket status (enforces the #1 rule). |
172
- | `autoFinalizeOnAgentEnd` | `true` | Set ticket "Done" when the triggered turn ends. |
173
- | `postUsage` | `true` | POST per-model token/cost to Aloita on `agent_end` (see §Usage post-back).|
174
- | `postDiff` | `true` | POST the run's git diff to Aloita's per-ticket diff viewer on `agent_end` (see §Run diff post-back).|
175
- | `streamAgentLog` | `true` | Stream the agent's text to Aloita's per-ticket agent-log tab in real time (see §Agent log streaming).|
176
- | `dedupWindowSeconds` | `2` | Webhook dedup window. |
177
- | `autoStartProjects` | `false` | Supervisor: poll `/api/projects` and spawn a child `pi` in each NEW project's folder (see §Auto-start supervisor). |
178
- | `autoStartPollSeconds` | `60` | Auto-start poll interval (seconds). |
179
- | `autoStartCreateFolder`| `true` | Auto-start: `mkdir -p` the project folder if it doesn't exist yet. |
180
- | `autoStartPiCommand` | `""` | Auto-start: override the `pi` command to spawn (default `pi.cmd`/`pi`). |
181
- | `autoStartExcludeProjects` | `[]` | Auto-start: project ids to never launch. |
182
- | `autoStartSeedExisting`| `false` | Auto-start: launch for all existing projects on startup (not just new). |
183
- | `autoStartProjectIds` | `[]` | Auto-start: allow-list of project ids to launch for (new projects do nothing until added here — use `/aloita-configure`). |
184
- | `capabilities` | `[]` | GLOBAL fallback caps registered against the auth user (see §Capability tiers). |
185
- | `userMappings` | `{}` | GLOBAL per-agent-user profiles + their capability tiers (see §Capability tiers). |
186
-
187
- ## How a ticket flows through Pi
188
-
189
- 1. **`session_start`** → opens the SignalR WebSocket to `<aloitaUrl>/hubs/webhooks`, handshakes, subscribes. Status footer shows `○ aloita: connected`.
190
- 2. **`WebhookEvent`** arrives → dedup (2s fingerprint) → resolve working dir → decide whether *this* session claims it (see *Multi-project* below) → fetch ticket context → resolve label → command → set **In Progress** + `agent-task/start` → `pi.sendUserMessage(prompt)`.
191
- 3. The Pi agent **works the ticket** using the native `aura_*` tools (get context, comment, attachments, checklist, conversations, search data…). Its streamed text is mirrored live to Aloita's per-ticket agent-log tab (see §Agent log streaming).
192
- 4. **`agent_end`** → finalize: set **Done** + `agent-task/end` → drain the next queued ticket if any.
193
- 5. **`StopTask`** (user clicks Stop in Aloita UI) → drops queued + `ctx.abort()` the active turn.
194
- 6. **`session_shutdown`** → closes the socket cleanly.
195
-
196
- ## Label routing (which prompt runs)
197
-
198
- When a ticket is claimed, its labels are matched (case-insensitive, trimmed, first match wins) against `labelToCommand` to pick the prompt template; any unmatched label (or no label) falls back to `defaultCommand`. Defaults (`src/config.ts`) — override/add in your `aloita.json`:
199
-
200
- | Label | Command | What the agent does |
201
- |------------------------|--------------------------------------|----------------------------------------------------------------------------------------------|
202
- | `plan` | `aura-plan-ticket` | Write a technical plan/spec, attach it; no code. |
203
- | `review` | `aura-review-ticket` | Review recent changes for the ticket; no code. |
204
- | `document` | `aura-document` | Generate documentation. |
205
- | `workflow-implementation` | `aloita-workflow-implement-with-pr` | Implement on a `feature/<name>` branch off latest `main`, then **open a PR into `staging`**. |
206
- | *(no / unmatched label)* | `aura-work-on-ticket` *(default)* | Implement the ticket directly (full work loop, no git/PR). |
207
-
208
- To add your own, map a label to a command name and define that template in `PROMPTS` (`src/prompts.ts`).
209
-
210
- ## Multi-project (one Pi per folder)
23
+ Set your server URL and API key via environment variables:
211
24
 
212
- A Pi session is bound to its launch cwd, so run **one Pi per project folder** — exactly the model `RUNNING.md` already documents for the supervisor:
213
-
214
- ```powershell
215
- # In project A's folder, dedicated to project A:
216
- cd C:\path\to\projectA
217
- pi --extension ...\aloita-extensions\src\index.ts
218
- # → set projectFilter to project A's id in .\.pi\aloita.json
219
- ```
220
-
221
- Ticket-claiming logic (`src/working-dir.ts shouldClaimTicket`):
222
- - `matchAllProjects: true` → claim everything (use this cwd).
223
- - `projectFilter: "<id>"` → claim only that project.
224
- - **default** → claim only when the ticket's resolved folder exists **and equals this session's cwd**.
225
-
226
- ## Auto-start supervisor
227
-
228
- Normally you start one Pi per project folder manually (`cd <folder> && pi`). With `autoStartProjects: true`, this Pi session becomes a **supervisor**: it polls `GET /api/projects` every `autoStartPollSeconds` and, for each project that is in your **allow-list** (`autoStartProjectIds`) and not yet launched, spawns a **detached child `pi`** in that project's folder.
229
-
230
- **Allow-list model:** a brand-new project does **nothing** until you explicitly opt it in — either by adding its id to `autoStartProjectIds` in the config file, or interactively via `/aloita-configure` (recommended). This keeps you in control of which folders get a child pi.
231
-
232
- ```json
233
- {
234
- "autoStartProjects": true,
235
- "repos": "C:\\Users\\frank\\source\\repos",
236
- "autoStartCreateFolder": true,
237
- "autoStartPollSeconds": 60
238
- }
25
+ ```sh
26
+ export ALOITA_URL="https://your-aloita-server"
27
+ export ALOITA_API_KEY="kai_your_key_here"
239
28
  ```
240
29
 
241
- **How a project is launched** (pure logic in `src/project-launcher.ts`, I/O in `src/index.ts`):
242
- 1. Poll `normalizeProjects` `detectProjectsToLaunch` (allow-listed projects not yet launched).
243
- 2. Resolve the folder, in order: `projectFolders[id]` override → project `LocalFolder` → `$REPOS/<sanitized-name>` (derived from `repos`).
244
- 3. If the folder doesn't exist and `autoStartCreateFolder` is on, `mkdir -p` it.
245
- 4. Write a tiny project-local `.pi/aloita.json` containing `{ "projectFilter": "<id>" }` so the child claims only that project.
246
- 5. `spawn(<pi>, { detached: true, stdio: "ignore" })` + `child.unref()` — the child survives the supervisor.
30
+ Or run `/aloita-configure` inside Pi to set them (and other options) in an
31
+ interactive settings screen values persist to `~/.pi/aloita.json` and apply
32
+ to every Pi session on the machine.
247
33
 
248
- Credentials are passed via `ALOITA_URL` / `ALOITA_API_KEY` env vars, **not** written into the per-project file, so no secrets are duplicated.
34
+ A `config.example.json` ships with the package; copy it to `~/.pi/aloita.json`
35
+ (global) or `<cwd>/.pi/aloita.json` (project-local) and edit.
249
36
 
250
- **Why polling:** Aloita's `WebhookEventType` bitmask only covers ticket events today — there is no `ProjectCreated` flag. Polling `/api/projects` is the server-agnostic signal. If Aloita adds a `ProjectCreated` event later, only the supervisor's trigger changes; the pure planning layer stays as-is.
251
-
252
- **Safe by default:** off unless you opt in; never blocks the session; failed launches/polls log a warning and continue.
253
-
254
- ## `/aloita-configure` — interactive settings UI
255
-
256
- The `/aloita-configure` command opens a full-screen TUI wizard (built from Pi's `SelectList` + `SettingsList` components) where you can manage all Aloita extension settings without editing JSON. All settings persist to the **global** config file (`~/.pi/aloita.json`) so they apply to every Pi session on this machine.
257
-
258
- - **Connection settings** — server URL, API key (`kai_…`), and agent user IDs. These are the credentials every Pi session needs to connect to Aloita. Set them once here and they're global — no need to repeat `ALOITA_URL`/`ALOITA_API_KEY` env vars per session. Changing them triggers an immediate SignalR reconnect.
259
- - **Model capabilities** — browse the models you've configured in Pi (from your providers — `ctx.modelRegistry.getAvailable()`) and toggle which to broadcast to Aloita. Costs are **auto-filled from Pi's model pricing** (`Model.cost`). The display name defaults to the model name. This is how Aloita learns what models this machine can run and what they cost — used for `/cost` and complexity-tier mapping.
260
- - **Auto-start projects** — browse every project on the Aloita board and toggle which ones are in the allow-list (`autoStartProjectIds`). Searchable for large boards.
261
- - **Auto-start options** — master switch, poll interval (30s/60s/2m/5m/10m presets), folder creation, seed-existing.
262
- - **General options** — status guard, auto-finalize, token-usage / diff / agent-log post-backs.
263
- - **Save & close** — persists changes to the config JSON file and hot-reloads. **Cancel** discards.
264
-
265
- The API key is masked in the input dialog (`kai_••••••`). Press Esc at any prompt to skip that field without changing it. Connection settings are written to `~/.pi/aloita.json` (global) — env vars (`ALOITA_URL`/`ALOITA_API_KEY`) still take precedence at load time if set.
266
-
267
- Changes persist via `src/config-store.ts` (read → merge → write), which shallow-merges so untouched keys survive and secrets (`aloitaUrl`/`apiKey`) are never written into the file.
37
+ Launch `pi`. The footer shows the connection status when the socket is up.
268
38
 
269
39
  ## Commands
270
40
 
271
- | Command | Action |
272
- |---------------------|-----------------------------------------------------------|
273
- | `/aloita-status` | Print connection state, active ticket, queue depth. |
274
- | `/aloita-connect` | Force reconnect the SignalR socket. |
275
- | `/aloita-disconnect`| Disconnect the socket. |
276
- | `/aloita-next` | Force-drain the next queued ticket into this session. |
277
- | `/aloita-configure` | Interactive settings UI: toggle auto-start projects, edit options, persist. |
278
-
279
- ## Tools (28, grouped like the MCP server)
280
-
281
- **Essential** — `aura_get_ticket_context`, `aura_add_comment`, `aura_upload_attachment`, `aura_update_ticket_status` *(guarded)*, `aura_create_subtask`, `aura_add_checklist_item`, `aura_toggle_checklist_item`, `aura_set_custom_field`.
282
-
283
- **Important** — `aura_search_tickets`, `aura_list_tickets`, `aura_update_ticket` *(status guarded)*, `aura_list_projects`, `aura_list_comments`, `aura_upload_comment_attachment`, `aura_list_attachments`, `aura_download_attachment`.
284
-
285
- **Nice-to-have** — `aura_assign_ticket`, `aura_create_ticket`, `aura_list_labels`, `aura_list_users`, `aura_log_time`, `aura_get_activity`.
286
-
287
- **Search data** — `aura_search_console_query`, `aura_ga4_report`, `aura_pagespeed_audit`.
288
-
289
- **Conversation** — `aura_start_conversation`, `aura_reply_in_conversation`, `aura_resolve_conversation`.
290
-
291
- > Asking a question (`aura_start_conversation`) used to require an exit/resume dance because `claude -p` was stateless. In Pi the session just idles — when the human answers, a new webhook event arrives and `sendUserMessage` continues the **same** session. No session-ID bookkeeping.
292
-
293
- ## Critical rules (preserved from the Python stack)
294
-
295
- - **Agents must not change ticket status.** Enforced at runtime by a `tool_call` interceptor (`guardStatus`): `aura_update_ticket_status` is blocked outright; the `status` field is stripped from `aura_update_ticket`. The orchestrator owns status (In Progress on start, Done on `agent_end`).
296
- - **Working dir must already exist** — never auto-created (`resolveWorkingDir` returns null if missing; the ticket is skipped with a log line).
297
- - **Run one SignalR subscription per Aloita auth user.** Like the receiver, one connection per Pi process.
298
- - **Stable per-run `sessionId`.** On `session_start` the extension mints one UUID (`runSessionId`) and sends it in the Subscribe payload's `SessionId` field, then reuses that same id for every per-ticket artifact — `agent-task/start`|`end`, usage, diff, and the agent-log stream. This lets the Agent Activity orbit render each Pi run as its own satellite and pin activity to it (without it, the orbit merges an agent's connections into a single satellite).
299
-
300
- ## Capability tiers (global, broadcast on connect)
301
-
302
- Capabilities tell Aloita what models this machine can run and what they cost. Aloita uses them two ways:
303
-
304
- 1. **Pricing** — upserts an `AgentModelPricing` row (`Source=ClientRegistered`, never clobbering an `AdminOverride`) so `/cost` can price runs.
305
- 2. **Complexity mapping** — links each model into the per-user complexity-tier admin UI, so Aloita's `AnalyzeTaskComplexity` flow can stamp the right model on a ticket based on its tier.
306
-
307
- This is **global config** (not per-project) — put it in `~/.pi/aloita.json` so every Pi session on this machine advertises the same set. Two sources, unioned:
308
-
309
- - `capabilities[]` — top-level fallback, registered against the auth user (the `.env` key).
310
- - `userMappings[<userId>].capabilities[]` — explicit per agent user (**preferred**).
311
-
312
- ```json
313
- "userMappings": {
314
- "927fc980-4cd6-4323-ac72-2a9be24f2730": {
315
- "tool": "pi",
316
- "capabilities": [
317
- { "tool": "pi", "provider": "anthropic", "model": "claude-sonnet-4-5",
318
- "displayName": "Sonnet 4.5 (medium)",
319
- "inputCostPerMTok": 3, "outputCostPerMTok": 15,
320
- "cachedInputCostPerMTok": 0.3, "cacheWriteCostPerMTok": 3.75 }
321
- ]
322
- }
323
- }
324
- ```
325
-
326
- **Auth binding:** Aloita attributes `RegisterCapabilities` to the *connected* user; only SuperAdmin can register for other users. So the auth user's caps go on the main connection; each *other* mapped user gets its own helper connection (authenticated with that user's `apiKey`, `registerOnly` mode — no Subscribe, just kept alive so the caps stay registered). Include `apiKey` on every non-auth `userMappings` entry.
327
-
328
- On `session_start`, the extension resolves the auth user via `GET /api/profile`, builds the plan (`src/capabilities.ts`), and starts the main + helper connections. `/aloita-connect` re-runs the whole flow.
329
-
330
- ## Usage post-back (on `agent_end`)
331
-
332
- When the Pi job for a ticket finishes (`agent_end`), the extension POSTs the run's token/cost to `/api/tickets/{id}/agent-run-usage`. This is the smart orchestration moment — usage is accumulated **while a ticket is active** and flushed exactly once when the turn ends.
333
-
334
- - **Source of truth:** Pi's per-assistant-message `usage` on `message_end` (`input`, `output`, `cacheRead`, `cacheWrite`, `totalTokens`, `cost.total`), plus `message.provider` / `message.model`. This is the Pi equivalent of Claude's authoritative `result.modelUsage`.
335
- - **Per-model breakdown:** aggregated by `(provider, model)` and sent as the `models[]` array Aloita's `AgentRunUsageEndpoints` expects. One `AgentRunUsage` row is written per model.
336
- - **Best-effort, never blocks:** posts happen after the turn ends and before finalize; a failure logs a warning but does not block status/queue drain.
337
- - **Zero-row visibility:** if no usage was captured (e.g. the turn was aborted), a single zero-token row is still posted so the run shows up in `/cost` rather than silently absent — matching the Python receiver.
338
- - **Auto-stub pricing:** Aloita auto-creates an `AgentModelPricing` stub for any unknown `(tool, provider, model)` triple, so cost is never a silent `$0`.
339
-
340
- Toggle with `postUsage: false` to disable.
341
-
342
- ## Run diff post-back (on `agent_end`)
343
-
344
- When the Pi job for a ticket finishes, the extension captures what the agent changed in the ticket's working directory and POSTs a unified diff to Aloita's per-ticket **diff viewer** — `POST /api/tickets/{id}/diffs` (`TicketDiffEndpoints.cs`). This is the "review without leaving the kanban" artifact, ported 1:1 from the Python receiver's `post_agent_run_diff`.
345
-
346
- - **Baseline snapshot at start:** when a ticket is injected, the extension snapshots the working dir's HEAD (`git rev-parse --short HEAD`) *before* the agent runs. The post-run diff is taken against that baseline, so it captures **both committed and uncommitted changes** made during this run — not the whole history.
347
- - **Untracked files included:** `git diff <base>` is appended with each untracked file (`git ls-files --others --exclude-standard`) rendered as a new-file diff via `git diff --no-index -- /dev/null <path>` — exactly the receiver's technique.
348
- - **Body:** `{ title, diffText, source: AgentRun(1), baseRef, headRef, sessionId }`. The server stores the raw diff verbatim and caches parsed summary counts (files/additions/deletions).
349
- - **Best-effort, never blocks:** runs last in `agent_end` (after status is finalized), gated on `postDiff`. A non-git folder, a git failure, or an API error logs a warning and never affects the ticket. `postDiff: false` disables it.
350
- - **No working dir → skipped:** when a ticket's working dir doesn't resolve (or isn't a git repo), capture is skipped silently.
351
-
352
- ## Agent log streaming (real time)
353
-
354
- While a ticket is active, the extension mirrors the agent's activity to Aloita's per-ticket **agent-log** tab in real time — a direct port of the Python receiver's `AuraAgentLogPusher`. This is what makes a run observable live (and reviewable afterward) without the old subprocess/dashboard.
355
-
356
- - **Same pipeline & contract as the receiver:** entries are buffered and flushed in batches of ≤ 50 every 0.5 s to `POST /api/tickets/{id}/agent-log/batch`, each carrying `{ content, sessionId, source, timestamp }`. Best-effort — a failed push logs a warning and never blocks the run.
357
- - **No subprocess → events are the source.** The old receiver read the CLI's stdout line-by-line. Here Pi *is* the agent, so the equivalent "display lines" are captured from Pi's streaming events and fed to the same pusher:
358
- - `message_update` → assistant **text deltas**, split into newline-delimited lines (the analog of the receiver's `read_line_unbounded`). Thinking blocks are intentionally not streamed, matching the old adapter which surfaced display text only.
359
- - **No tool noise.** Tool calls and results (`▶ bash(…)` / `✓ bash` / `✗ … (error)` markers) are intentionally **not** logged — only the assistant's own text and the lifecycle markers are streamed, to keep the log readable.
360
- - **Lifecycle mirrors the receiver:** a `--- started ---` line is pushed when the ticket is injected; the pusher is drained + flushed (`close()`) in `agent_end` **before** finalize/usage (the same ordering the receiver's `finally` block uses), and on StopTask/shutdown.
361
- - **Toggle:** `streamAgentLog: false` disables it with zero overhead (no pusher is created; the feed handlers short-circuit).
362
-
363
- The implementation is isolated in `src/agent-log.ts` (`AgentLogPusher`, `TextLineBuffer`, `createAuraAgentLogPoster`), unit-tested with Node's built-in test runner (`npm test`).
41
+ | Command | Action |
42
+ |----------------------|-------------------------------------------------|
43
+ | `/aloita-status` | Show connection state, active ticket, queue. |
44
+ | `/aloita-connect` | Force reconnect. |
45
+ | `/aloita-disconnect` | Disconnect. |
46
+ | `/aloita-next` | Drain the next queued ticket. |
47
+ | `/aloita-configure` | Interactive settings UI. |
364
48
 
365
49
  ## Logging
366
50
 
367
- The extension **never writes to stdout/stderr** — that would corrupt Pi's TUI rendering. All diagnostics (connection events, webhook handling, usage/diff post-backs, auto-start launches, capability registration) go to a **log file**:
51
+ Diagnostics go to `~/.pi/aloita.log` (never stdout/stderr, which would corrupt
52
+ Pi's TUI). Set the level via `ALOITA_LOG_LEVEL` (`debug`/`info`/`warn`/`error`/`off`,
53
+ default `info`) and the path via `ALOITA_LOG_FILE`.
368
54
 
369
- ``
370
- ~/.pi/aloita.log
371
- ```
372
-
373
- Tail it in a separate terminal while Pi runs:
374
-
375
- ```powershell
376
- Get-Content ~/.pi/aloita.log -Wait -Tail 20
377
- # or on Linux/macOS:
378
- tail -f ~/.pi/aloita.log
379
- ```
380
-
381
- **Log levels** (set via `ALOITA_LOG_LEVEL`, case-insensitive): `debug` > `info` (default) > `warn` > `error` > `off`.
382
-
383
- **Custom log file** (optional): set `ALOITA_LOG_FILE` to any path (`~` is expanded).
384
-
385
- User-facing events (ticket started/finished, errors) still appear as transient toast notifications via `ctx.ui.notify()`, and connection state lives in the footer via `ctx.ui.setStatus()` — the correct Pi UI channels. `/aloita-status` shows a concise one-line summary as a toast; the full detail goes to the log file.
55
+ ## License
386
56
 
387
- ## Status & caveats
388
-
389
- This is a v1 port. Tested live: REST (`GET /api/projects` → 28 projects), SignalR (connect → handshake → subscribe → ping → clean disconnect), capability plan build + auth-user resolution, and usage POST + read-back all pass against the real server.
390
-
391
- Known differences from the Python receiver, by design:
392
- - **Subscription billing only.** The old receiver had two billing paths (Agent SDK credit via `claude -p`, and subscription via the loop bridge). A live Pi session is subscription-billed.
393
- - **No web dashboard.** Visibility is the Pi status footer + notifications + `/aloita-status`. The full event log / terminal-stream dashboard is gone.
394
- - **`autoFinalizeOnAgentEnd` marks Done after the triggered turn.** If the agent posts a `aura_start_conversation` and stops, the ticket is still marked Done (matching the old receiver's behavior on `claude -p` exit) — the conversation is resumed when the next webhook arrives. Set `autoFinalizeOnAgentEnd: false` to keep tickets In Progress until a human moves them.
395
-
396
- ## Layout
397
-
398
- ```
399
- src/
400
- ├── index.ts # entry: lifecycle, webhook→sendUserMessage, queue, finalize, status guard, usage flush, commands
401
- ├── config.ts # env + JSON config loader (global userMappings + capabilities)
402
- ├── aura-client.ts # fetch-based port of aura_client.py (get/post/put/delete/upload/download)
403
- ├── signalr.ts # hand-rolled SignalR JSON protocol over ws (main + registerOnly helper mode)
404
- ├── tools.ts # all 28 aura_* tools (port of server.py)
405
- ├── prompts.ts # embedded command templates + label→command routing
406
- ├── capabilities.ts # builds the capability broadcast plan (main + per-user helper split)
407
- ├── agent-log.ts # real-time agent-log streamer (port of AuraAgentLogPusher) + TextLineBuffer + tool-summary helpers
408
- ├── usage.ts # accumulates per-model token/cost, posts to /agent-run-usage on agent_end
409
- ├── working-dir.ts # resolveWorkingDir + shouldClaimTicket (port of resolve_working_dir)
410
- ├── project-launcher.ts # pure auto-start logic (normalize/detect/plan) — see §Auto-start supervisor
411
- ├── config-store.ts # read/write/merge the JSON config file for /aloita-configure
412
- ├── aloita-configure.ts # interactive /aloita-configure settings UI (SelectList + SettingsList)
413
- ```
57
+ MIT
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./config.schema.json",
3
- "aloitaUrl": "https://srv1322862.hstgr.cloud",
3
+ "aloitaUrl": "https://your-aloita-server",
4
4
  "apiKey": "kai_REPLACE_ME",
5
5
 
6
6
  "subscription": {
@@ -12,16 +12,16 @@
12
12
  "allProjects": true,
13
13
  "allUsers": false,
14
14
  "projectIds": [],
15
- "userIds": ["5fcb57d1-f3a8-4501-b6f6-82228425b6a3"]
15
+ "userIds": ["00000000-0000-0000-0000-000000000000"]
16
16
  },
17
17
 
18
18
  "projectFolders": {
19
- "00000000-0000-0000-0000-000000000000": "C:\\Users\\frank\\source\\repos\\my-project"
19
+ "00000000-0000-0000-0000-000000000000": "/path/to/my-project"
20
20
  },
21
21
 
22
22
  "projectFilter": "",
23
23
  "matchAllProjects": false,
24
- "repos": "C:\\Users\\frank\\source\\repos",
24
+ "repos": "/path/to/your/repos",
25
25
 
26
26
  "labelToCommand": {
27
27
  "plan": "aura-plan-ticket",
@@ -34,35 +34,35 @@
34
34
  "guardStatus": true,
35
35
  "autoFinalizeOnAgentEnd": true,
36
36
  "postUsage": true,
37
- "// postDiff": "Capture the run's git diff (vs the HEAD snapshot taken at ticket start) and POST it to Aloita's per-ticket diff viewer (POST /api/tickets/{id}/diffs). Port of the Python receiver's post_agent_run_diff. Best-effort; never blocks or fails the ticket.",
37
+ "// postDiff": "Capture the run's git diff and post it to the per-ticket diff viewer. Best-effort; never blocks the ticket.",
38
38
  "postDiff": true,
39
- "// streamAgentLog": "Stream the agent's text + tool activity to Aloita's per-ticket agent-log tab in real time (POST /api/tickets/{id}/agent-log/batch). Port of the Python receiver's AuraAgentLogPusher. Best-effort; never blocks.",
39
+ "// streamAgentLog": "Stream the agent's text to the per-ticket agent-log tab in real time. Best-effort; never blocks.",
40
40
  "streamAgentLog": true,
41
41
  "dedupWindowSeconds": 2,
42
42
 
43
- "// logging": "Diagnostics go to ~/.pi/aloita.log (never stdout/stderr, which would corrupt Pi's TUI). Override the path via env ALOITA_LOG_FILE. Level via ALOITA_LOG_LEVEL (debug/info/warn/error/off, default info). See README §Logging.",
43
+ "// logging": "Diagnostics go to ~/.pi/aloita.log. Override the path via env ALOITA_LOG_FILE. Level via ALOITA_LOG_LEVEL (debug/info/warn/error/off, default info).",
44
44
 
45
- "// autoStart": "Supervisor mode: when on, this Pi session periodically polls /api/projects and spawns a detached child `pi` in each NEW project's folder, so a Pi CLI is online the moment a project is created — no manual `cd <folder> && pi`. Off by default. See README §Auto-start supervisor.",
45
+ "// autoStart": "Supervisor mode: when on, periodically check for new projects and spawn a detached child pi in each new project's folder. Off by default.",
46
46
  "autoStartProjects": false,
47
47
  "// autoStartPollSeconds": "Poll interval for new projects, in seconds.",
48
48
  "autoStartPollSeconds": 60,
49
- "// autoStartCreateFolder": "mkdir the project folder if it doesn't exist yet (a brand-new Aloita project usually has no folder on disk). This is what makes 'no manual step' actually work.",
49
+ "// autoStartCreateFolder": "Create the project folder if it doesn't exist yet.",
50
50
  "autoStartCreateFolder": true,
51
51
  "// autoStartPiCommand": "Override the pi command to spawn (defaults to pi.cmd on Windows, pi elsewhere).",
52
52
  "autoStartPiCommand": "",
53
53
  "// autoStartExcludeProjects": "Project ids to never auto-start.",
54
54
  "autoStartExcludeProjects": [],
55
- "// autoStartSeedExisting": "When true, launch a child pi for EVERY existing project on supervisor startup (not just new ones). Default false — only newly-created projects are launched.",
55
+ "// autoStartSeedExisting": "When true, launch a child pi for every existing project on startup (not just new ones). Default false.",
56
56
  "autoStartSeedExisting": false,
57
- "// autoStartProjectIds": "Allow-list of project ids that the supervisor should auto-start a child pi for. A brand-new project does nothing until you add its id here. Easiest via /aloita-configure.",
57
+ "// autoStartProjectIds": "Allow-list of project ids to auto-start a child pi for. A new project does nothing until added here. Easiest via /aloita-configure.",
58
58
  "autoStartProjectIds": [],
59
59
 
60
- "// capabilities": "GLOBAL fallback caps registered against the auth user. Prefer userMappings below. Each entry pushes pricing into Aloita AND links the model into the per-user complexity-tier admin UI.",
60
+ "// capabilities": "Global fallback model capabilities registered against the auth user. Prefer userMappings below.",
61
61
  "capabilities": [],
62
62
 
63
- "// userMappings": "Per-agent-user profiles. Key = Aloita agent user id. The auth user (env key) may omit apiKey; OTHER users must include their own apiKey so their capabilities register over a connection authenticated as them (Aloita binds RegisterCapabilities to the connected user).",
63
+ "// userMappings": "Per-agent-user profiles. Key = agent user id. The auth user may omit apiKey; other users must include their own apiKey.",
64
64
  "userMappings": {
65
- "927fc980-4cd6-4323-ac72-2a9be24f2730": {
65
+ "00000000-0000-0000-0000-000000000000": {
66
66
  "tool": "pi",
67
67
  "capabilities": [
68
68
  {
package/package.json CHANGED
@@ -1,25 +1,14 @@
1
1
  {
2
2
  "name": "aloita-extensions",
3
- "version": "0.4.7",
4
- "description": "Pi extensions that replace the Aura SignalR receiver + Aura MCP server. A single Pi session connects to Aloita (formerly Aura) over SignalR and works on tickets natively.",
3
+ "version": "0.4.8",
4
+ "description": "A Pi extension: one Pi session connects to an Aloita server and works on tickets natively.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "aloita",
8
- "aura",
9
- "kanban",
10
- "signalr",
11
8
  "agent"
12
9
  ],
13
10
  "license": "MIT",
14
- "author": "Frank Vliegen <frankvl76@users.noreply.github.com>",
15
- "homepage": "https://github.com/frankvl76/aloita-extensions#readme",
16
- "repository": {
17
- "type": "git",
18
- "url": "git+https://github.com/frankvl76/aloita-extensions.git"
19
- },
20
- "bugs": {
21
- "url": "https://github.com/frankvl76/aloita-extensions/issues"
22
- },
11
+ "author": "Frank Vliegen",
23
12
  "type": "module",
24
13
  "engines": {
25
14
  "node": ">=18"
@@ -31,7 +20,6 @@
31
20
  },
32
21
  "files": [
33
22
  "src/",
34
- "docs/",
35
23
  "README.md",
36
24
  "LICENSE",
37
25
  "config.example.json",
package/src/index.ts CHANGED
@@ -44,11 +44,15 @@ import { createExtensionLogger, type LogSink, type AloitaLogger } from "./logger
44
44
  import {
45
45
  detectProjectsToLaunch,
46
46
  normalizeProjects,
47
+ parsePid,
48
+ pidFilePath,
47
49
  planLaunch,
50
+ reconcileLaunched,
51
+ resolveProjectDir,
48
52
  type LaunchPlan,
49
53
  type ProjectInfo,
50
54
  } from "./project-launcher.ts";
51
- import { mkdir, writeFile } from "node:fs/promises";
55
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
52
56
  import { spawn } from "node:child_process";
53
57
 
54
58
  interface QueuedTicket {
@@ -474,6 +478,20 @@ export default function aloitaExtension(pi: ExtensionAPI) {
474
478
 
475
479
  // ── Auto-start supervisor ──────────────────────────────────────────────
476
480
 
481
+ /** Best-effort liveness probe for a pid: `process.kill(pid, 0)` sends no
482
+ * signal and only reports whether the process exists. EPERM (exists but
483
+ * not ours) counts as alive; ESRCH (no such process) counts as dead.
484
+ * Used by the supervisor to tell a previously-launched child pi is still
485
+ * running so it won't re-spawn a duplicate on restart. */
486
+ function probePidAlive(pid: number): boolean {
487
+ try {
488
+ process.kill(pid, 0);
489
+ return true;
490
+ } catch (e) {
491
+ return (e as NodeJS.ErrnoException).code === "EPERM";
492
+ }
493
+ }
494
+
477
495
  /** Polls `/api/projects` and spawns a detached child `pi` in each new
478
496
  * project's folder, so a Pi CLI is online the moment a project is created.
479
497
  * Started on `session_start` when `autoStartProjects` is on; stopped on
@@ -482,8 +500,11 @@ export default function aloitaExtension(pi: ExtensionAPI) {
482
500
  private timer: ReturnType<typeof setTimeout> | null = null;
483
501
  private running = false;
484
502
  /** Project ids we've already launched during this supervisor lifetime, so
485
- * we never double-launch even if the allow-list keeps mentioning them. */
486
- private readonly launched = new Set<string>();
503
+ * we never double-launch even if the allow-list keeps mentioning them.
504
+ * Reconciled against persistent pidfiles on every tick so that a
505
+ * restarted supervisor recognizes children that are still running —
506
+ * the fix for the duplicate-sessions-on-restart bug. */
507
+ private launched = new Set<string>();
487
508
 
488
509
  start(): void {
489
510
  if (this.timer) return;
@@ -521,6 +542,16 @@ export default function aloitaExtension(pi: ExtensionAPI) {
521
542
  try {
522
543
  const raw = await client.get("/api/projects");
523
544
  const projects = normalizeProjects(raw);
545
+ // Reconcile against persistent pidfiles FIRST: a freshly-restarted
546
+ // supervisor has an empty `launched` set, but the detached child pi
547
+ // processes from the previous session are still alive. Without this
548
+ // step, detectProjectsToLaunch would return every allow-listed
549
+ // project and we'd spawn duplicates. Projects whose pidfile is stale
550
+ // (child exited) are dropped from the set so they can be re-launched.
551
+ this.launched = reconcileLaunched(
552
+ this.launched,
553
+ await this.collectPidStatuses(projects),
554
+ );
524
555
  // Allow-list model: launch only for projects the operator opted in
525
556
  // (autoStartProjectIds) and hasn't launched yet this lifetime.
526
557
  const toLaunch = detectProjectsToLaunch(this.launched, config.autoStartProjectIds, projects);
@@ -569,6 +600,51 @@ export default function aloitaExtension(pi: ExtensionAPI) {
569
600
  shell: process.platform === "win32",
570
601
  });
571
602
  child.unref();
603
+ // Record the child's pid next to its config so a future/restarted
604
+ // supervisor can detect it is still running and skip re-launching
605
+ // (the duplicate-session fix). On Windows `shell:true` the pid is the
606
+ // cmd.exe wrapper, which stays alive exactly as long as pi does, so it
607
+ // is a faithful liveness proxy on both platforms.
608
+ if (typeof child.pid === "number" && child.pid > 0) {
609
+ try {
610
+ await writeFile(pidFilePath(plan.cwd), String(child.pid), "utf8");
611
+ } catch (e) {
612
+ log(`[aloita] auto-start pidfile write failed for ${plan.projectId}: ${(e as Error).message}`);
613
+ }
614
+ }
615
+ }
616
+
617
+ /** Read each project's pidfile (when its folder resolves) and probe the
618
+ * recorded pid for liveness. Used by {@link tick} to reconcile the
619
+ * in-memory `launched` set against persistent state. Stale pidfiles
620
+ * (dead pid) are cleaned up here so they don't linger. */
621
+ private async collectPidStatuses(projects: ProjectInfo[]) {
622
+ const allowList = new Set(config.autoStartProjectIds);
623
+ const statuses = [];
624
+ for (const project of projects) {
625
+ if (!allowList.has(project.id)) continue;
626
+ const dir = resolveProjectDir(project, config);
627
+ if (!dir) continue;
628
+ const file = pidFilePath(dir);
629
+ let content: string | null = null;
630
+ try {
631
+ content = await readFile(file, "utf8");
632
+ } catch {
633
+ // No pidfile yet — never launched, or launched this lifetime
634
+ // before the write completed. Nothing to reconcile.
635
+ continue;
636
+ }
637
+ const pid = parsePid(content);
638
+ if (pid === null) continue;
639
+ const alive = probePidAlive(pid);
640
+ if (!alive) {
641
+ // Stale: the child is gone. Best-effort cleanup so the file
642
+ // doesn't keep referencing a recycled pid later.
643
+ try { await rm(file, { force: true }); } catch { /* best-effort */ }
644
+ }
645
+ statuses.push({ projectId: project.id, pid, alive });
646
+ }
647
+ return statuses;
572
648
  }
573
649
  }
574
650
 
@@ -21,7 +21,11 @@ import {
21
21
  detectNewProjects,
22
22
  detectProjectsToLaunch,
23
23
  normalizeProjects,
24
+ parsePid,
25
+ pidFilePath,
24
26
  planLaunch,
27
+ reconcileLaunched,
28
+ resolveProjectDir,
25
29
  shouldAutoStart,
26
30
  type AutoStartConfig,
27
31
  type ProjectInfo,
@@ -321,3 +325,98 @@ describe("planLaunch — plan shape", () => {
321
325
  assert.match(res.reason, /allow-list/);
322
326
  });
323
327
  });
328
+
329
+ // ── pidfile helpers (duplicate-session fix) ───────────────────────────────
330
+
331
+ describe("pidFilePath", () => {
332
+ it("places the pidfile in <dir>/.pi/aloita.autostart.pid", () => {
333
+ const p = pidFilePath("/repos/alpha");
334
+ assert.match(p, /[\\/]\.pi[\\/]aloita\.autostart\.pid$/);
335
+ });
336
+ });
337
+
338
+ describe("parsePid", () => {
339
+ it("parses a plain integer", () => {
340
+ assert.equal(parsePid("12345"), 12345);
341
+ });
342
+
343
+ it("tolerates surrounding whitespace/newlines", () => {
344
+ assert.equal(parsePid(" 4242\n"), 4242);
345
+ });
346
+
347
+ it("returns null for empty / missing content", () => {
348
+ assert.equal(parsePid(null), null);
349
+ assert.equal(parsePid(undefined), null);
350
+ assert.equal(parsePid(""), null);
351
+ assert.equal(parsePid(" "), null);
352
+ });
353
+
354
+ it("returns null for non-numeric or non-positive content", () => {
355
+ assert.equal(parsePid("not-a-pid"), null);
356
+ assert.equal(parsePid("0"), null);
357
+ assert.equal(parsePid("-5"), null);
358
+ });
359
+ });
360
+
361
+ describe("resolveProjectDir", () => {
362
+ it("mirrors planLaunch folder resolution (override wins)", () => {
363
+ const cfg = baseConfig({ repos: "/repos", projectFolders: { p1: "$REPOS/alpha" } });
364
+ assert.equal(resolveProjectDir({ id: "p1", name: "Alpha" }, cfg), "/repos/alpha");
365
+ });
366
+
367
+ it("derives $REPOS/<slug> when nothing else is set", () => {
368
+ const cfg = baseConfig({ repos: "/repos" });
369
+ assert.equal(resolveProjectDir({ id: "p1", name: "My Cool Project" }, cfg), "/repos/my-cool-project");
370
+ });
371
+
372
+ it("returns null when nothing resolves", () => {
373
+ const cfg = baseConfig({ repos: "" });
374
+ assert.equal(resolveProjectDir({ id: "p1", name: "Alpha" }, cfg), null);
375
+ });
376
+ });
377
+
378
+ describe("reconcileLaunched", () => {
379
+ it("adds projects whose child is still alive (the restart/duplicate fix)", () => {
380
+ // Fresh supervisor after restart: launched set is empty, but the child
381
+ // for p1 is still running (alive pidfile). p1 must NOT be re-launched.
382
+ const out = reconcileLaunched(new Set(), [
383
+ { projectId: "p1", pid: 111, alive: true },
384
+ ]);
385
+ assert.ok(out.has("p1"));
386
+ });
387
+
388
+ it("removes projects with a stale (dead) pidfile so they re-launch", () => {
389
+ const out = reconcileLaunched(new Set(["p1", "p2"]), [
390
+ { projectId: "p1", pid: 111, alive: false }, // child died
391
+ ]);
392
+ assert.ok(!out.has("p1"), "p1 should be dropped (stale)");
393
+ assert.ok(out.has("p2"), "p2 untouched");
394
+ });
395
+
396
+ it("leaves projects with no pidfile untouched", () => {
397
+ // Launched this lifetime before the pidfile existed — don't lose it,
398
+ // and don't add unknown projects either.
399
+ const out = reconcileLaunched(new Set(["p3"]), []);
400
+ assert.ok(out.has("p3"));
401
+ assert.equal(out.size, 1);
402
+ });
403
+
404
+ it("combines alive/stale/unknown in one pass", () => {
405
+ const out = reconcileLaunched(new Set(["p1", "p4"]), [
406
+ { projectId: "p1", pid: 1, alive: true }, // keep (alive)
407
+ { projectId: "p2", pid: 2, alive: true }, // add (alive)
408
+ { projectId: "p3", pid: 3, alive: false }, // not present, no-op (was never in set)
409
+ { projectId: "p4", pid: 4, alive: false }, // drop (stale)
410
+ ]);
411
+ assert.ok(out.has("p1"));
412
+ assert.ok(out.has("p2"));
413
+ assert.ok(!out.has("p3"));
414
+ assert.ok(!out.has("p4"));
415
+ });
416
+
417
+ it("does not mutate the input set", () => {
418
+ const input = new Set(["p1"]);
419
+ reconcileLaunched(input, [{ projectId: "p2", pid: 9, alive: true }]);
420
+ assert.deepEqual([...input], ["p1"]);
421
+ });
422
+ });
@@ -145,6 +145,81 @@ export interface SkipResult {
145
145
  reason: string;
146
146
  }
147
147
 
148
+ /** Resolve the launch folder for a project using the same precedence as
149
+ * {@link planLaunch} (override → LocalFolder → $REPOS/<slug>). Returns the
150
+ * absolute dir, or `null` when nothing resolves. Exposed so the controller
151
+ * can locate a project's pidfile for liveness reconciliation without having
152
+ * to run a full {@link planLaunch} (which would also enforce create-folder
153
+ * rules we don't want during a read-only reconcile). */
154
+ export function resolveProjectDir(
155
+ project: ProjectInfo,
156
+ config: AutoStartConfig,
157
+ ): string | null {
158
+ return resolveLaunchFolder(project, config).dir;
159
+ }
160
+
161
+ /** Path to the pidfile the supervisor writes next to the project-local
162
+ * config: `<dir>/.pi/aloita.autostart.pid`. Holds the PID of the detached
163
+ * child `pi` so a restarted supervisor can tell that project's child is
164
+ * still running and skip re-launching it — the fix for the duplicate-sessions
165
+ * bug. */
166
+ export function pidFilePath(dir: string): string {
167
+ return resolve(dir, ".pi", "aloita.autostart.pid");
168
+ }
169
+
170
+ /** Parse a pidfile's contents into a positive integer PID, or `null` when
171
+ * the content is missing/empty/not a usable integer. Tolerant of trailing
172
+ * whitespace/newlines. Pure. */
173
+ export function parsePid(content: string | null | undefined): number | null {
174
+ if (content == null) return null;
175
+ const n = Number.parseInt(content.trim(), 10);
176
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
177
+ }
178
+
179
+ /** Liveness snapshot for one project's previously-launched child, as
180
+ * determined by its pidfile + a liveness probe supplied by the controller
181
+ * (via `process.kill(pid, 0)`). */
182
+ export interface PidStatus {
183
+ projectId: string;
184
+ /** Pid read from the pidfile, or `null` when no pidfile exists. */
185
+ pid: number | null;
186
+ /** True when `pid` is alive (probed by the controller). Always `false`
187
+ * when `pid` is `null`. */
188
+ alive: boolean;
189
+ }
190
+
191
+ /** Reconcile the supervisor's `launched` set against pidfile liveness.
192
+ *
193
+ * This is the core of the duplicate-session fix. Because the `launched` set
194
+ * is in-memory and per-supervisor-lifetime, a fresh supervisor (after a Pi
195
+ * restart) would otherwise see an empty set and re-spawn children that are
196
+ * still running. Reconciling against the persistent pidfiles makes that set
197
+ * survive restarts:
198
+ *
199
+ * - a project whose child is **still alive** → added to the set (skip it);
200
+ * - a project with a **stale** (dead) pidfile → removed from the set so it
201
+ * can be re-launched (the child exited/crashed);
202
+ * - a project with **no pidfile at all** → left untouched (the controller
203
+ * may have launched it this lifetime before the file existed, or it simply
204
+ * hasn't been launched yet).
205
+ *
206
+ * Returns a new Set; does not mutate the input. Pure given `statuses`. */
207
+ export function reconcileLaunched(
208
+ launched: Set<string>,
209
+ statuses: PidStatus[],
210
+ ): Set<string> {
211
+ const out = new Set(launched);
212
+ for (const s of statuses) {
213
+ if (s.alive) {
214
+ out.add(s.projectId);
215
+ } else if (s.pid !== null) {
216
+ // Stale pidfile: the child is gone, so allow a fresh launch.
217
+ out.delete(s.projectId);
218
+ }
219
+ }
220
+ return out;
221
+ }
222
+
148
223
  /** Outcome of {@link planLaunch}: either an executable plan or a skip reason. */
149
224
  export type PlanResult = LaunchPlan | ({ skip: true } & SkipResult);
150
225
 
@@ -1,65 +0,0 @@
1
- # Aloita Status Bar — Indicator Styling (before / after)
2
-
3
- Ticket: **Pi Status Bar: Improve Aloita Connection Indicator Styling**
4
-
5
- ## Requirements (all met)
6
-
7
- 1. ✅ Status circle (dot) **green** to signal an active connection
8
- 2. ✅ Label **capitalized** to "Aloita" (was "aloita")
9
- 3. ✅ "connected" text **green** for visual clarity
10
-
11
- ## How it renders
12
-
13
- Color is applied via Pi's `Theme.fg(color, text)` (`ctx.ui.theme`), which embeds
14
- ANSI codes the footer renders natively. `success` = green by default and respects
15
- the user's theme. The footer's `sanitizeStatusText` does not strip ANSI, and its
16
- `truncateToWidth` (from `@earendil-works/pi-tui`) is ANSI-aware, so truncation
17
- stays correct.
18
-
19
- ### Before
20
-
21
- ```
22
- ○ aloita: connected
23
- ● aloita: connected · working #abc-123 · +2 queued
24
- ✕ aloita: connection lost: timeout
25
- ```
26
- (all one color — the terminal's default fg)
27
-
28
- ### After
29
-
30
- ```
31
- {green}○{reset} Aloita: {green}connected{reset}
32
- {green}●{reset} Aloita: {green}connected{reset} · working #abc-123 · +2 queued
33
- {dim}✕{reset} Aloita: {dim}connection lost: timeout{reset}
34
- ```
35
-
36
- Concrete ANSI (what `Theme.fg` actually emits):
37
-
38
- | State | Dot | Label | Connection |
39
- |---|---|---|---|
40
- | Connected & idle | `\x1b[32m○\x1b[39m` (green hollow) | `Aloita` | `\x1b[32mconnected\x1b[39m` (green) |
41
- | Working a ticket | `\x1b[32m●\x1b[39m` (green filled) | `Aloita` | `\x1b[32mconnected\x1b[39m` (green) |
42
- | Disconnected | `\x1b[2m✕\x1b[39m` (dim) | `Aloita` | `\x1b[2m<connectionState>\x1b[39m` (dim) |
43
-
44
- ## Design decisions
45
-
46
- - **Green = "active connection" only.** Reserved for the connected/working
47
- indicator as the ticket specifies. The working/queue suffix is left uncolored
48
- so the green indicator stands out.
49
- - **Disconnected is dim, not red.** Neutral — avoids crying wolf during benign
50
- states like "not started" / "stopped" at startup. The `✕` glyph already
51
- communicates "down". (A red-for-real-errors variant is a trivial follow-up if
52
- desired: pass `"error"` instead of `"dim"` in `buildStatusText`.)
53
- - **Internal `setStatus` key stays lowercase `"aloita"`.** The footer renders
54
- only the value, not the key; the capitalized "Aloita" is the visible label.
55
-
56
- ## Files
57
-
58
- - `src/status.ts` — pure `buildStatusText(input, colorizer)` + types
59
- - `src/status.test.ts` — 12 unit tests (`node:test`, fake tag-wrapping colorizer)
60
- - `src/index.ts` — `renderStatus` delegates to `buildStatusText(..., ctx.ui.theme)`
61
-
62
- ## Verification
63
-
64
- - `npm run build` (`tsc --noEmit`): **green**
65
- - `npm test`: **42/42 pass** (12 new + 30 prior)
@@ -1,274 +0,0 @@
1
- # Pi Extension — UI Customisation Limits
2
-
3
- Ticket: **Investigate UI Customisation Limits of the Pi Extension**
4
-
5
- Goal: document the technical boundaries of what the Aloita Pi extension can do
6
- to Pi's UI — not to commit to an implementation, but to define what options are
7
- on the table for future design decisions. The headline question we were asked:
8
- **"Could we introduce a side navigation panel? What other structural UI
9
- modifications are feasible?"**
10
-
11
- Source material:
12
- - Pi docs — `docs/tui.md`, `docs/extensions.md` (installed with
13
- `@earendil-works/pi-coding-agent`)
14
- - Pi example extensions — `examples/extensions/custom-footer.ts`,
15
- `custom-header.ts`, `overlay-qa-tests.ts`, `plan-mode/`, `modal-editor.ts`,
16
- `doom-overlay/`
17
- - Current Aloita extension UI surface — `src/status.ts`, `src/index.ts`
18
- (uses `ctx.ui.setStatus` + `ctx.ui.notify` only)
19
-
20
- ---
21
-
22
- ## 1. The UI surface Pi exposes to extensions
23
-
24
- Everything below is reachable from the extension's `ctx` (event handlers,
25
- commands, tools) or from `pi` (factory). All `ctx.ui.*` component factories and
26
- `ctx.ui.custom()` require `ctx.mode === "tui"`; in RPC mode the fire-and-forget
27
- helpers (`notify`, `setStatus`, `setWidget`) still work, and in print / JSON
28
- mode `ctx.hasUI === false`.
29
-
30
- ### 1.1 Layout-region hooks (structural)
31
-
32
- | Hook | What it replaces | Reclaims space? | Persistent? |
33
- |---|---|---|---|
34
- | `ctx.ui.setStatus(key, text)` | One footer status slot (keyed) | No — slot in existing footer | Yes |
35
- | `ctx.ui.setFooter(factory)` | The **entire footer** row | Yes — owns the row | Yes |
36
- | `ctx.ui.setHeader(factory)` | The **entire header** (logo + keybinding hints) | Yes — owns the row | Yes |
37
- | `ctx.ui.setWidget(key, …, { placement })` | A block **above** (default) or **below** the editor | Yes — inserts a region | Yes |
38
- | `ctx.ui.setEditorComponent(factory)` | The **input editor** itself | Yes — owns that region | Yes |
39
- | `ctx.ui.setWorkingMessage / setWorkingVisible / setWorkingIndicator` | The streaming "working" loader row | No — existing row | Streaming-only |
40
- | `ctx.ui.setTitle(text)` | Terminal title (OSC 0/2) | n/a | Yes |
41
- | `ctx.ui.setTheme(name \| Theme)` | Whole color theme | n/a | Yes |
42
-
43
- All of these are vertical regions stacked top-to-bottom:
44
-
45
- ```
46
- ┌──────── header (setHeader) ────────────┐
47
- │ message stream │ ← NOT replaceable by extensions
48
- │ │ (only message_end content mutation)
49
- │ ── widget above ── │ ← setWidget (default placement)
50
- │ input editor │ ← setEditorComponent
51
- │ ── widget below ── │ ← setWidget({ placement: "belowEditor" })
52
- │ working loader │ ← setWorkingIndicator / setWorkingMessage
53
- └──────── footer (setFooter / setStatus) ┘
54
- ```
55
-
56
- **There is no "split the message stream into a left/right column" hook.** This
57
- is the single biggest structural limitation (see §3).
58
-
59
- ### 1.2 Dialogs (modal, blocking)
60
-
61
- `ctx.ui.select / confirm / input / editor` — all support `{ timeout }` and
62
- `AbortSignal`. `notify(msg, "info"|"warning"|"error")` is non-blocking.
63
-
64
- ### 1.3 Custom TUI components & overlays (the powerful one)
65
-
66
- `ctx.ui.custom(component)` — replaces the whole screen with an arbitrary
67
- `Component` (`render(width): string[]`, `handleInput`, `invalidate`). Useful for
68
- wizards, menus, games (the `snake.ts` / `space-invaders.ts` / `doom-overlay/`
69
- examples prove real-time interaction works).
70
-
71
- `ctx.ui.custom(component, { overlay: true, overlayOptions })` — renders the
72
- component **on top of** the existing UI without clearing it. Options:
73
-
74
- | Option | Values |
75
- |---|---|
76
- | `anchor` | 9 positions: `top-left`, `top-center`, `top-right`, `left-center`, `center`, `right-center`, `bottom-left`, `bottom-center`, `bottom-right` |
77
- | `width` / `minWidth` / `maxHeight` | number or `"%"` string |
78
- | `offsetX` / `offsetY` | integer offset from anchor |
79
- | `margin` | number, or `{ top, right, bottom, left }` |
80
- | `row` / `col` | absolute or `%` positioning (alternative to anchor) |
81
- | `visible: (termW, termH) => boolean` | **responsive auto-hide** (e.g. hide < 100 cols) |
82
- | `onHandle(handle)` | programmatic `focus()`, `unfocus({ target })`, `setHidden(bool)`, `hide()` |
83
-
84
- Overlay capabilities (all demonstrated in `overlay-qa-tests.ts`):
85
-
86
- - **Multiple overlays can stack**, with z-order and focus cycling (Tab).
87
- - **Passive / non-capturing overlays** — visible alongside the active overlay
88
- and the editor; user keeps typing.
89
- - **Real-time updates** at ~30 FPS (`doom-overlay`, `overlay-animation`) —
90
- refresh-driven by `tui.requestRender()`.
91
- - **IME-aware inputs** via the `Focusable` interface + `CURSOR_MARKER`.
92
- - **Toggle visibility** programmatically via `handle.setHidden()`.
93
-
94
- ### 1.4 Other customisation hooks
95
-
96
- | Hook | Purpose |
97
- |---|---|
98
- | `ctx.ui.addAutocompleteProvider(...)` | Layer custom completions on top of built-in slash/path completion (e.g. `#ticket-id`, `@user`) |
99
- | `ctx.ui.setEditorText / getEditorText / pasteToEditor` | Editor content control |
100
- | `ctx.ui.setToolsExpanded(bool)` | Tool output expansion default |
101
- | `ctx.ui.getAllThemes / getTheme / setTheme` | Theme management; can ship a custom theme file |
102
- | Tool `renderCall(args, theme, ctx)` / `renderResult(result, opts, theme, ctx)` | Custom rendering per `aura_*` tool call/result (ticket cards, status pills, diff stats) |
103
- | `pi.registerCommand / registerShortcut / registerFlag` | Slash commands, keybindings, CLI flags |
104
- | `before_agent_start` (`message` + `systemPrompt`) | Inject persistent messages or rewrite the system prompt |
105
- | `message_end` (return `{ message }`) | Mutate finalised message content (not chrome) |
106
-
107
- ---
108
-
109
- ## 2. The side-navigation-panel question
110
-
111
- ### TL;DR
112
-
113
- | Tier | Approach | True side-by-side? | Recommended? |
114
- |---|---|---|---|
115
- | A | Reserve a column in the message area | ✅ (ideal) | ❌ **Not exposed by Pi** — would require an upstream change |
116
- | B | Right-anchored **overlay** panel | ⚠️ Overlays occlude, they don't reflow | ✅ **Closest feasible analog** — 1-day spike |
117
- | C | `setWidget` block above/below editor | ❌ (horizontal strip, not a column) | ✅ For a compact queue/active-ticket strip |
118
-
119
- ### Tier A — true persistent side-by-side: NOT available
120
-
121
- Pi's extension layout model is **vertical regions only** (§1.1). There is no
122
- `setSidePanel`, no `splitMain`, no column manager. The message-stream column
123
- width is fixed by the terminal width and is not adjustable from an extension.
124
- Any "side nav that lives next to the conversation and squeezes it" requires an
125
- upstream Pi change. Worth proposing upstream if this becomes important, but out
126
- of scope for an extension today.
127
-
128
- ### Tier B — overlay side panel: FEASIBLE (recommended spike)
129
-
130
- This is the closest analog and is already proven by the `SidepanelComponent` in
131
- `overlay-qa-tests.ts` (command `/overlay-sidepanel`). Sketch:
132
-
133
- ```typescript
134
- pi.registerCommand("aloita-panel", {
135
- handler: async (_args, ctx) => {
136
- await ctx.ui.custom<void>((tui, theme, _kb, done) =>
137
- new AloitaSidePanel(tui, theme, done), {
138
- overlay: true,
139
- overlayOptions: {
140
- anchor: "right-center",
141
- width: "25%",
142
- minWidth: 30,
143
- margin: { right: 1 },
144
- visible: (w) => w >= 100, // auto-hide on narrow terminals
145
- },
146
- });
147
- },
148
- });
149
- ```
150
-
151
- **Capabilities** (all already supported):
152
- - Anchored to the right edge, full height, ~25% width, with a 1-col margin.
153
- - Auto-hides below 100 cols (the `visible` callback).
154
- - **Non-capturing** variant lets the user keep typing in the editor while the
155
- panel is visible (`overlay-passive` example pattern).
156
- - Can be toggled by a `pi.registerShortcut` keybinding, with
157
- `handle.setHidden()` to show/hide without rebuilding.
158
- - Refresh-driven by `tui.requestRender()` on Aloita events
159
- (`WebhookEvent`, status changes, queue depth).
160
- - Can render live data: connection state, active ticket (title + status),
161
- queued tickets, recent activity feed — all of which the extension already
162
- holds in `src/index.ts` (`activeTicket`, `pendingQueue`, `connectionState`).
163
-
164
- **Caveat (the key boundary):**
165
- > **Overlays paint over content; they do not reflow it.**
166
- > A 25%-width right-anchored panel covers the rightmost 25% of the message
167
- > stream. Long code lines under the panel are occluded, not wrapped. This is
168
- > acceptable for a status/nav panel (short lines, user can dismiss with Esc),
169
- > but it is not a substitute for a true split layout.
170
-
171
- ### Tier C — widget-as-panel: partial alternative
172
-
173
- `ctx.ui.setWidget("aloita-nav", lines, { placement: "belowEditor" })` is
174
- persistent, reflows naturally, and never occludes — but it is a horizontal
175
- strip, not a vertical column. Good for a compact one-line-per-item queue:
176
-
177
- ```
178
- aloita: ● connected · working #abc — Fix login · +2 queued
179
- ↑ this is what we already render via setStatus
180
- ```
181
-
182
- A `setWidget` block below the editor could show the queue + active ticket as a
183
- multi-line strip. This is the lowest-effort visible upgrade and stacks cleanly
184
- with Tier B (panel for detail, widget for at-a-glance).
185
-
186
- ---
187
-
188
- ## 3. Other structural UI modifications that ARE feasible
189
-
190
- Ranked by value-to-Aloita / effort:
191
-
192
- 1. **Custom footer** (`setFooter`) — we already compute the data in
193
- `buildStatusText`; `setFooter` would let us own the whole row (model,
194
- token cost, git branch, Aloita status). Trivial follow-up to current work.
195
- 2. **Custom header** (`setHeader`) — replace the logo + keybinding hints with
196
- the Aloita project name, active model, connection state. Same factory shape
197
- as `setFooter`.
198
- 3. **Custom autocomplete** (`addAutocompleteProvider`) — `#ticket-id` →
199
- surface matching tickets, `@user` → users, `/aloita-` → our commands.
200
- Triggers on `#`, `@`, `/`.
201
- 4. **Rich tool renderers** (`renderCall` / `renderResult` on the 28 `aura_*`
202
- tools) — render `aura_get_ticket_context` as a ticket card, `aura_list_tickets`
203
- as a kanban-style list, `aura_search_console_query` as a mini table, diffs as
204
- stat pills (`+12 -3`). This is where the biggest perceived-UX win lives,
205
- without touching layout.
206
- 5. **Custom editor keybindings** (`setEditorComponent` wrapping the default) —
207
- e.g. `<C-a>t` to insert the active ticket link, `<C-a>n` to inject
208
- `/aloita-next`. Modal-editing example shows the pattern.
209
- 6. **Custom working indicator** (`setWorkingIndicator`) — theme the streaming
210
- spinner to Aloita colours.
211
- 7. **Custom Aloita theme** — ship a `.pi` theme file branded to Aloita.
212
- 8. **Overlay dialogs for high-value prompts** — e.g. when `aura_start_conversation`
213
- fires, surface the question as a `SelectList` overlay the user can answer
214
- with arrow keys instead of typing.
215
-
216
- ---
217
-
218
- ## 4. Hard limitations / things we CANNOT do from an extension
219
-
220
- 1. **No split-layout / column API.** Cannot reserve a side column next to the
221
- message stream (§2, Tier A). This is the one structural modality that would
222
- require an upstream Pi change.
223
- 2. **Overlays occlude, they don't reflow.** Tier B panels cover content rather
224
- than squeezing the conversation.
225
- 3. **The message-stream rendering is not customisable.** Extensions can mutate
226
- message *content* (`message_end` → `{ message }`) but cannot replace the
227
- per-message chrome (avatars, role labels, spacing). Tools have
228
- `renderCall` / `renderResult`; user/assistant messages do not.
229
- 4. **TUI mode only for full UI.** `ctx.ui.custom`, all `set*Component`/
230
- `setFooter`/`setHeader` factories, and component overlays require
231
- `ctx.mode === "tui"`. RPC mode degrades to `notify` / `setStatus` /
232
- `setWidget`; print / JSON modes have `ctx.hasUI === false`. Any UI feature
233
- we ship must guard on `ctx.mode === "tui"` and degrade gracefully when the
234
- session is driven headlessly by an Aloita webhook.
235
- 5. **No HTML/DOM.** Pi is a terminal app; all rendering is ANSI/Unicode. A
236
- web-style side nav with icons, images-in-DOM, etc. is not on the table
237
- (terminal images *are* supported via the `Image` component for
238
- Kitty/iTerm2/Ghostty/WezTerm/Warp, but layout is still cell-based).
239
- 6. **One SignalR subscription per Aloita auth user.** Architectural constraint
240
- from `src/signalr.ts`; bounds how many independent live-data panes we can
241
- sensibly fan out (not really a UI limit, but it bounds what a "multi-project
242
- dashboard panel" could show without extra helper connections).
243
-
244
- ---
245
-
246
- ## 5. Recommendations
247
-
248
- For the immediate goal of "more structural UI for Aloita":
249
-
250
- 1. **Spike Tier B — `/aloita-panel` overlay** (1 day). Use the existing
251
- `SidepanelComponent` as a template. Wire it to the extension's existing
252
- `activeTicket` / `pendingQueue` / `connectionState`. Make it non-capturing
253
- and toggleable. This is the closest thing to a side navigation panel and
254
- answers the headline question with a working prototype.
255
- 2. **Promote `setStatus` → `setFooter`** (half day). Owns the whole footer row
256
- and lets us show model + cost + branch alongside the Aloita status we
257
- already render.
258
- 3. **Add `#ticket-id` autocomplete** (half day). High perceived polish for
259
- low effort.
260
- 4. **Rich `aura_*` tool renderers** (1–2 days). The biggest UX win that does
261
- not touch layout — ticket cards, kanban lists, diff stat pills.
262
- 5. **Reserve Tier A (true split layout) as an upstream Pi feature request.**
263
- If the overlay panel proves valuable but the occlusion caveat bites, that
264
- is the moment to file an issue on Pi proposing a `setSidePanel`-style
265
- column-aware layout manager.
266
-
267
- ---
268
-
269
- ## 6. Verification
270
-
271
- - `npm run build` (`tsc --noEmit`): **green**
272
- - Investigation is documentation-only; no source changes were made to
273
- `src/`. All APIs referenced are taken from the installed
274
- `@earendil-works/pi-coding-agent` docs and example extensions.