osdy-pi 1.4.1 → 1.6.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 (58) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +54 -21
  3. package/extensions/osdy-pi/agent-coexistence-setup.test.ts +162 -1
  4. package/extensions/osdy-pi/agent-coexistence-setup.ts +130 -38
  5. package/extensions/osdy-pi/codex-usage-ui.ts +2 -32
  6. package/extensions/osdy-pi/locales/de.json +16 -0
  7. package/extensions/osdy-pi/locales/en.json +15 -0
  8. package/extensions/osdy-pi/locales/es.json +16 -0
  9. package/extensions/osdy-pi/locales/fr.json +16 -0
  10. package/extensions/osdy-pi/locales/pt-BR.json +16 -0
  11. package/extensions/osdy-pi/locales/pt.json +16 -0
  12. package/extensions/osdy-pi/locales/ru.json +16 -0
  13. package/extensions/osdy-pi/locales/uk.json +16 -0
  14. package/extensions/osdy-pi/locales/zh.json +16 -0
  15. package/extensions/osdy-pi/message-role-markers.test.ts +161 -0
  16. package/extensions/osdy-pi/message-role-markers.ts +52 -0
  17. package/extensions/osdy-pi/modal-frame.test.ts +78 -0
  18. package/extensions/osdy-pi/modal-frame.ts +35 -0
  19. package/extensions/osdy-pi/runtime.test.ts +56 -31
  20. package/extensions/osdy-pi/runtime.ts +47 -11
  21. package/extensions/osdy-pi/todo-command.test.ts +132 -0
  22. package/extensions/osdy-pi/todo-command.ts +60 -0
  23. package/extensions/osdy-pi/todo-config.test.ts +67 -0
  24. package/extensions/osdy-pi/todo-config.ts +78 -0
  25. package/extensions/osdy-pi/todo-domain.test.ts +106 -0
  26. package/extensions/osdy-pi/todo-domain.ts +183 -0
  27. package/extensions/osdy-pi/todo-i18n.test.ts +52 -0
  28. package/extensions/osdy-pi/todo-i18n.ts +43 -0
  29. package/extensions/osdy-pi/todo-panel.test.ts +163 -0
  30. package/extensions/osdy-pi/todo-panel.ts +88 -0
  31. package/extensions/osdy-pi/todo-session.test.ts +81 -0
  32. package/extensions/osdy-pi/todo-session.ts +75 -0
  33. package/extensions/osdy-pi/todo-tool-render.test.ts +63 -0
  34. package/extensions/osdy-pi/todo-tool-render.ts +60 -0
  35. package/extensions/osdy-pi/todo-tool.test.ts +139 -0
  36. package/extensions/osdy-pi/todo-tool.ts +104 -0
  37. package/extensions/osdy-pi/todo-widget.test.ts +235 -0
  38. package/extensions/osdy-pi/todo-widget.ts +111 -0
  39. package/extensions/osdy-pi/types.ts +1 -0
  40. package/extensions/osdy-pi/uninstall.test.ts +142 -0
  41. package/extensions/osdy-pi/uninstall.ts +141 -0
  42. package/extensions/osdy-pi/working-animation.test.ts +79 -14
  43. package/extensions/osdy-pi/working-animation.ts +8 -2
  44. package/package.json +11 -2
  45. package/themes/osdy-pi-catppuccin-frappe.json +1 -1
  46. package/themes/osdy-pi-catppuccin-latte.json +1 -1
  47. package/themes/osdy-pi-catppuccin-macchiato.json +1 -1
  48. package/themes/osdy-pi-catppuccin-mocha.json +1 -1
  49. package/themes/osdy-pi-dark.json +1 -1
  50. package/themes/osdy-pi-dracula.json +1 -1
  51. package/themes/osdy-pi-kanagawa-dragon.json +1 -1
  52. package/themes/osdy-pi-kanagawa-lotus.json +1 -1
  53. package/themes/osdy-pi-kanagawa-wave.json +1 -1
  54. package/themes/osdy-pi-lucent-orange.json +1 -1
  55. package/themes/osdy-pi-matrix.json +1 -1
  56. package/themes/osdy-pi-new.json +1 -1
  57. package/themes/osdy-pi-sexy.json +1 -1
  58. package/themes/osdy-pi-tokyo-night.json +1 -1
package/LICENSE CHANGED
@@ -1,6 +1,11 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026 Osdy
4
+ Copyright (c) 2026 juicesharp
5
+
6
+ TODO portions are adapted from @juicesharp/rpiv-todo@2.11.0
7
+ (https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-todo),
8
+ licensed under the MIT terms below.
4
9
 
5
10
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
11
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -8,10 +8,11 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
8
8
 
9
9
  | Area | What ships |
10
10
  | --- | --- |
11
- | Message cards | Pi-native, theme-aware user and assistant cards; assistant accents follow the theme and user accents are white. |
11
+ | Message cards | Pi-native colored user cards without emojis and plain assistant Markdown preceded by a separate `🦝` transcript row. |
12
12
  | Header and mascot | A theme-aware `neon` header and selectable `bts` mascot, both responsive and persisted independently. |
13
13
  | Accounts | `/osdy-account` switches Codex profiles in place with atomic activation, rollback, bounded auth files, and process-safe locks. |
14
14
  | Quota | `/usage` and compact bars emphasize remaining Codex quota at warning (40% or less) and error (15% or less) thresholds. |
15
+ | TODO (starting in 1.5.0) | First-party `todo` snapshots track the Pi session branch; `/todos` reads them without editing. ODD Markdown remains separate. |
15
16
 
16
17
  ## Prerequisites
17
18
 
@@ -33,14 +34,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
33
34
  pi install git:github.com/OsdyOrtiz/Osdy-Pi
34
35
  ```
35
36
 
36
- Already installed a pinned `npm:osdy-pi@1.3.0` or `npm:osdy-pi@1.4.0` in **normal Pi**? Once 1.4.1 is published, replace the older pin (no uninstall or `--local` needed):
37
-
38
- ```bash
39
- pi install npm:osdy-pi@1.4.1
40
- pi list
41
- ```
42
-
43
- Check that `pi list` shows `npm:osdy-pi@1.4.1`, then restart Pi or run `/reload`. `pi update` does not move a pinned npm version. Installing Osdy does not automatically install Joker; use the [explicit agents setup](#explicit-joker-agents-setup-in-normal-pi) after updating if you want Joker in normal Pi.
37
+ Already using a pinned npm version? `pi update` does not move a pinned version. First-party TODO is included starting in 1.5.0. Starting in 1.6.0, `/osdy-pi uninstall` and explicit agent provider modes are available. Check the resolved version when installing from npm or use a 1.6.0-or-later checkout to try these changes. Installing Osdy does not automatically install Joker; see [explicit agent provider modes](#explicit-agent-provider-modes-in-normal-pi).
44
38
 
45
39
  2. Start Pi:
46
40
 
@@ -82,7 +76,6 @@ Add these maintained extensions after installing Osdy Pi. They are optional; eac
82
76
  | `pi-playwright` | Playwright browser-automation skills. |
83
77
  | `pi-mcp-adapter` | MCP server and tool adapter for Pi. |
84
78
  | `pi-subagents-j0k3r` | Markdown-defined subagents, delegation tools, history, and model profiles. |
85
- | `@juicesharp/rpiv-todo` | A persistent live todo overlay for the agent. |
86
79
  | `gentle-pi` | The Gentle senior-architect harness, with SDD/OpenSpec, subagents, TDD evidence, and skills. |
87
80
 
88
81
  Install the ordinary npm packages once:
@@ -98,7 +91,6 @@ pi install npm:@open-pets/pi
98
91
  pi install npm:pi-playwright
99
92
  pi install npm:pi-mcp-adapter
100
93
  pi install npm:pi-subagents-j0k3r
101
- pi install npm:@juicesharp/rpiv-todo
102
94
  ```
103
95
 
104
96
  `gentle-pi` is deliberately not included in that package-install block: clone and register it through the coexistence setup below, which applies Osdy-specific exclusions instead of adding a duplicate ordinary package entry.
@@ -108,7 +100,7 @@ git clone https://github.com/Gentleman-Programming/gentle-pi.git
108
100
  osdy-pi gentle setup "$(pwd)/gentle-pi"
109
101
  ```
110
102
 
111
- ## Explicit Joker agents setup in normal Pi
103
+ ## Explicit agent provider modes in normal Pi
112
104
 
113
105
  After `pi install npm:osdy-pi`, open **normal personal Pi** (not `osdy`'s isolated profile) and run:
114
106
 
@@ -116,9 +108,11 @@ After `pi install npm:osdy-pi`, open **normal personal Pi** (not `osdy`'s isolat
116
108
  /osdy-pi agents setup
117
109
  ```
118
110
 
119
- Review the confirmation: it names `~/.pi/agent/settings.json`, installs `npm:pi-subagents-j0k3r` using Pi's package command, and adds only `-extensions/gentle-agents.ts` to each eligible Gentle package entry. It preserves unrelated settings and Gentle resources; if no Gentle package is present, it only installs Joker. After success, Pi reloads resources (restart Pi if reload fails). `/osdy-pi agents status` checks the personal Joker/Gentle setup without writing. Cancel changes nothing; rerunning is safe. This command is available inside Pi after the Pi package install—no global npm CLI or package postinstall hook is required.
111
+ `/osdy-pi agents setup` and `/osdy-pi agents on` ensure **Joker mode**: install Joker with Pi if absent, enable its package extension (`./index.ts`), and exclude only `-extensions/gentle-agents.ts` from eligible Gentle entries. `/osdy-pi agents off` selects **Gentle mode**: require an eligible Gentle package first, exclude only Joker's `./index.ts` using `-./index.ts` in its package entry (if installed), and remove Gentle's agents exclusion. Both packages and all unrelated resources stay installed. `/osdy-pi agents status` reports the settings-derived mode (`joker`, `gentle`, `mixed`, or `unavailable`) without writing. Confirm the named `~/.pi/agent/settings.json` change; after success Pi reloads resources, or prompts a restart if reload fails. Cancel changes nothing; repeating either mode is safe. No runtime task-failure fallback is provided.
120
112
 
121
- Gentle package detection supports `npm:gentle-pi` and validated local checkouts (absolute, settings-relative, `~/`, and `file:` paths). Remote Git/HTTPS Gentle sources are not reconciled: when recognized, setup stops before installing Joker rather than reporting a false success. Register a local Gentle checkout or npm Gentle source in personal Pi first; the existing `osdy-pi gentle setup /absolute/path/to/gentle-pi` command can configure local coexistence if you have that CLI. Setup refuses an isolated/custom `$PI_CODING_AGENT_DIR` or a project-local Gentle override because a personal filter cannot reliably control those contexts. Run it in normal Pi without a conflicting project package; an invalid settings file or failed install produces an error rather than silently applying only part of the filter. It does not edit credentials.
113
+ A pre-existing Joker `-./index.ts` filter without Osdy's ownership marker is ambiguous and blocks switching rather than being removed. Osdy records ownership in the personal settings field `osdyPiJokerExclusionOwned` while it owns that filter and removes the field on return to Joker mode. Existing Gentle agents exclusions are removed by `off`, including exclusions configured before this feature; review that change before confirming. Other package fields and extension filters remain intact. An extension allowlist that omits the agent extension needed for the selected mode blocks switching rather than silently reporting success.
114
+
115
+ Gentle package detection supports `npm:gentle-pi` and validated local checkouts (absolute, settings-relative, `~/`, and `file:` paths). Remote Git/HTTPS Gentle sources are not reconciled: when recognized, setup stops before installing Joker rather than reporting a false success. Register a local Gentle checkout or npm Gentle source in personal Pi first; the existing `osdy-pi gentle setup /absolute/path/to/gentle-pi` command can configure local coexistence if you have that CLI. Mode switching refuses an isolated/custom `$PI_CODING_AGENT_DIR` or a project-local Gentle override because a personal filter cannot reliably control those contexts. Run it in normal Pi without a conflicting project package; an invalid settings file or failed install produces an error rather than silently applying only part of the filter. It does not edit credentials.
122
116
 
123
117
  ## Gentle coexistence setup
124
118
 
@@ -128,11 +122,48 @@ To load a local `gentle-pi` checkout while keeping Osdy Pi's UI authoritative, r
128
122
  osdy-pi gentle setup /absolute/path/to/gentle-pi
129
123
  ```
130
124
 
131
- The command validates that the absolute source is a readable `gentle-pi` package with Gentle's todo and agents extensions before atomically updating `$PI_CODING_AGENT_DIR/settings.json` (or `~/.pi/agent/settings.json`). It registers the local package immediately before the first configured Osdy Pi package entry, so Gentle Shell initializes first, with exclusions only for Gentle's `gentle-todo.ts` and `gentle-agents.ts`, plus `themes: []`. Restart Pi or run `/reload` after setup.
125
+ The command validates that the absolute source is a readable `gentle-pi` package with Gentle's todo and agents extensions before atomically updating `$PI_CODING_AGENT_DIR/settings.json` (or `~/.pi/agent/settings.json`). It registers the local package immediately before the first configured Osdy Pi package entry, so Gentle Shell initializes first, with exclusions for Gentle's `gentle-todo.ts`, `ask-user-question.ts`, and `gentle-agents.ts`, plus `themes: []`. Restart Pi or run `/reload` after setup.
132
126
 
133
127
  Gentle Shell intentionally remains fully active underneath Osdy, including its footer and widgets. While Osdy is enabled, Osdy claims the footer and editor; disabling Osdy restores the editor Gentle Shell provided at session startup. Gentle's changes widget may coexist with Osdy's visual widgets.
134
128
 
135
- The exclusions prevent Gentle's todo and agents extensions from competing with the suite. `@juicesharp/rpiv-todo` remains the authoritative todo overlay, `pi-subagents-j0k3r` remains the authoritative subagent system, and `@juicesharp/rpiv-ask-user-question` remains the authoritative structured-question plugin; their package entries are left untouched.
129
+ The exclusions prevent Gentle's todo, questionnaire, and agents extensions from competing with the suite. Osdy's session-branch snapshots are authoritative for its `todo` tool and read-only `/todos` command; `pi-subagents-j0k3r` is the subagent system in Joker mode (Gentle agents in Gentle mode) and `@juicesharp/rpiv-ask-user-question` remains the structured-question plugin. This setup does **not** remove an independently installed `@juicesharp/rpiv-todo` package or edit its package entry. Remove that package yourself as described below to avoid duplicate `todo` and `/todos` registrations.
130
+
131
+ ## First-party TODO included starting in 1.5.0
132
+
133
+ The model-facing `todo` tool supports `create`, `update`, `list`, `get`, `delete`, and `clear`. Tool results carry full task snapshots; Pi's current session branch is the TODO authority. Session switches and compaction replay the latest valid branch snapshot, rather than sharing one project-wide list. `/todos` opens a read-only, grouped modal (pending, in progress, completed) in interactive Pi; scroll with arrow or Page Up/Down keys and close with Esc or q. The refreshed modal adds a completed-task progress meter, theme-aware status groups and a scroll-position footer while keeping every task reachable. It is not an interactive Markdown editor. In non-terminal modes with UI support, it retains notification output. ODD's `odd/tasks/*.md` ledger and Engram are separate orchestration records: there is no automatic sync with these Pi TODOs.
134
+
135
+ In interactive Pi, the persistent widget appears above the editor while visible tasks exist. By default it shows up to five task rows (excluding heading, overflow hint, and spacer), summarizes overflow, and keeps completed rows visible with crossed-out subjects on subsequent turns; expand tool output to view all rows. `ctrl+shift+t` collapses or expands the widget.
136
+
137
+ ### Try session TODO
138
+
139
+ Use an installed 1.5.0-or-later package (or a checkout), ask the agent to track a task with `todo`, then run `/todos`. The widget shows up to five tasks by default; the modal shows the entire grouped list with arrow/Page Up/Down scrolling. If your `maxWidgetLines` configuration overrides the default, set it to `7` to show five task rows.
140
+
141
+ **Verification to date (not complete):** Prior feature checks passed 187 extension tests, 60 script tests, typecheck, and lint. A disposable Pi TUI with seeded tasks showed the modal's progress meter, grouped rows, scroll footer, and close behavior. Authenticated RPC turns exercised all six `todo` actions, and an authenticated TUI smoke observed widget and `/todos` updates. An RPC lifecycle check observed an empty new session and the completed task on switching back. Compaction replay remains unverified because Pi returned “Nothing to compact (session too small).” Language switching in the authenticated TODO flow remains unverified (an isolated TUI with both extensions did switch English to Spanish). Visual strikethrough of completed rows in the authenticated TUI remains unverified. Launch under an installed Osdy account profile remains unverified. These checks do not establish full feature parity or validate a published npm artifact.
142
+
143
+ ### TODO configuration and language
144
+
145
+ Create `$XDG_CONFIG_HOME/rpiv-todo/config.json` (with an absolute `XDG_CONFIG_HOME`); when absent, Osdy falls back to `~/.config/rpiv-todo/config.json`. Invalid JSON uses defaults. For example:
146
+
147
+ ```json
148
+ {
149
+ "maxWidgetLines": 7,
150
+ "collapseKey": "ctrl+shift+t",
151
+ "guidance": {
152
+ "promptSnippet": "Track multi-step work",
153
+ "promptGuidelines": ["Update task status as work progresses"]
154
+ }
155
+ }
156
+ ```
157
+
158
+ `maxWidgetLines` must be a number >= 3 (otherwise 7); it is read on widget renders. `collapseKey` accepts a key combination or `"off"` to disable the shortcut; the shortcut binds at extension load, so restart Pi or run `/reload` after changing it. `guidance.promptSnippet` and `guidance.promptGuidelines` override the tool's model guidance at registration; reload after changing them. The optional `@juicesharp/rpiv-i18n` peer enables nine bundled locales (`de`, `en`, `es`, `fr`, `pt-BR`, `pt`, `ru`, `uk`, `zh`); without the SDK the UI uses English. To use `/languages`, also load its Pi extension (for example, `pi install npm:@juicesharp/rpiv-i18n` in your chosen profile); the peer dependency alone does not register that command. An isolated Pi TUI with both extensions loaded switched the TODO widget and `/todos` from English to Spanish.
159
+
160
+ **Already installed `@juicesharp/rpiv-todo`?** Both packages can register `todo` and `/todos`. Check `pi list` in the Pi profile you intend to use; to use Osdy's first-party implementation, explicitly remove the standalone package in that same profile:
161
+
162
+ ```bash
163
+ pi remove npm:@juicesharp/rpiv-todo
164
+ ```
165
+
166
+ For a project-local installation, run `pi remove -l npm:@juicesharp/rpiv-todo` in that project instead. Restart Pi or run `/reload`, then check `/todos` shows session tasks, not a document panel. Osdy never uninstalls another package or silently changes package settings. If you need the standalone package, disable the conflicting extension via Pi package configuration instead.
136
167
 
137
168
  ## OpenAI account profiles
138
169
 
@@ -215,7 +246,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
215
246
  | --- | --- |
216
247
  | Themes | 14 built-in themes, including Osdy, Kanagawa, Dracula, Catppuccin, Matrix, and Lucent Orange palettes |
217
248
  | Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
218
- | Messages | Pi-native, theme-aware user and assistant message cards |
249
+ | Messages | Native colored user cards without emojis and plain assistant Markdown with a separate assistant `🦝` row |
219
250
  | Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
220
251
  | Status | Theme-aware Braille spinner and animated working label, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
221
252
  | Git | Working-tree summary and a centered, filterable diff panel |
@@ -300,6 +331,7 @@ The enabled state, editor mode, working-tree visibility preference, header, and
300
331
  | Main | `/osdy-pi` |
301
332
  | Main | `/osdy-pi enable\|disable\|on\|off\|status` |
302
333
  | Accounts | `/osdy-account` |
334
+ | Session TODO (starting in 1.5.0) | `/todos` (read-only) |
303
335
  | Header | `/osdy-pi header osdy-theme\|neon\|status` |
304
336
  | Mascot | `/osdy-pi mascot current\|bts\|status` |
305
337
  | Editor | `/osdy-pi editor auto\|extended\|simple\|on\|off\|toggle\|status` |
@@ -307,8 +339,11 @@ The enabled state, editor mode, working-tree visibility preference, header, and
307
339
  | Working tree | `/osdy-pi working-tree position top\|bottom\|status` |
308
340
  | Audio | `/osdy-pi sound setup` |
309
341
  | Diff | `/osdy-pi diff` |
342
+ | Package | `/osdy-pi uninstall` |
310
343
  | Codex subscription | `/usage` |
311
344
 
345
+ `/osdy-pi uninstall` finds a unique Osdy Pi package in Pi's configured package list and asks you to confirm its exact source and user/project scope before calling Pi's `remove` command. Run it from a trusted project with interactive UI. Git registrations are checked against Pi's host/path identity; a local registration must point to this running extension's package root and have a matching manifest. Ambiguous or changed registrations, unrecognized sources, and cancelled confirmation remove nothing. It removes only the Pi package registration, not Osdy profiles, accounts, the globally installed CLI, or other packages. Restart Pi afterward to unload the extension. If no unique source is found, inspect `pi list` and use `pi remove <source> [-l]` manually.
346
+
312
347
  `/osdy-pi` reports status. `enable` (or `on`) applies the Osdy Pi UI without changing the selected Pi theme; `disable` (or `off`) restores the Gentle Shell or Pi UI captured at session startup while preserving that theme. The enabled state, editor mode, working-tree visibility, and sound configuration persist globally.
313
348
 
314
349
  ### Codex subscription usage
@@ -325,9 +360,7 @@ Usage loads once when the session starts and refreshes when the modal opens or `
325
360
 
326
361
  ## Native message cards
327
362
 
328
- Osdy Pi delegates conversation rendering to Pi's native message-card components. It does not install a Markdown transformer or inject card markup into assistant responses. This preserves Pi's own streaming, selection, and Markdown behavior while allowing every bundled Osdy theme to style the native cards.
329
-
330
- User and assistant cards have separate background, text, and accent tokens. Assistant accents follow each theme's primary accent; user accents are white for a consistent visual distinction. Disabling Osdy Pi continues to restore the underlying Gentle Shell or Pi presentation normally.
363
+ Pi renders the colored user card and assistant Markdown natively. User messages have no emoji; Osdy Pi places a separate `🦝` transcript entry before each assistant response with visible text, not inside message content. Previously saved user `👤` entries remain in session history but render no row on replay. Selecting only the native message keeps its body clean; a wide selection that includes the emoji row copies the emoji too. Assistant markers persist with the session but do not enter model context. The installed Pi 0.87.1 does not use `assistantMessage*` theme palette keys, so these keys do not create a native assistant card. Osdy does not install a Markdown transformer or change streaming. The current user card colors remain unchanged. Try both roles in your selected theme with `npm run pi:dev`; terminal rendering and selection still need a live visual check. When disabled, Osdy does not add new markers.
331
364
 
332
365
  ## Editor and working indicator
333
366
 
@@ -335,7 +368,7 @@ The default `auto` editor mode preserves the responsive behavior: it uses the fr
335
368
 
336
369
  In auto or extended mode at a non-small width, the framed editor shows the model and thinking level in its title and session usage in its footer. For an account-profile launch, the left title shows the active profile name instead of `Osdy-Pi`. When the native editor is effective (simple mode or any small terminal), the Osdy footer instead shows model, active profile when present, thinking, and usage rows before its path/branch and status rows. It uses the currently active Pi/Osdy theme palette; no separate editor theme selector exists. Usage covers input, output, cache read, cache write when present, cost, and context. If Pi supports autocomplete, the editor uses Pi's native autocomplete rendering while the completion UI is visible.
337
370
 
338
- While work is active, Osdy Pi shows the original Braille spinner above the editor in the selected theme's accent color. A highlight travels across the `Working...` and `Running ...` letters using the active theme's accent and text colors; the spinner and label update when the theme changes. Osdy Pi hides Pi's built-in working row while enabled to avoid a duplicate indicator.
371
+ While work is active, Osdy Pi shows the original Braille spinner above the editor in the selected theme's accent color. A two-color wave travels across the `Working...` and `Running ...` letters: the current letter uses the active theme's `accent`, the trailing letter uses `warning`, and the rest use `text`. Only the current `accent` letter tries the active theme's bold styling; this is a terminal-dependent visual trial, not a change to font size. The trailing `warning` letter stays plain, as does the accent spinner. The spinner and label update when the theme changes. Osdy Pi hides Pi's built-in working row while enabled to avoid a duplicate indicator.
339
372
 
340
373
  ## Working tree and diff
341
374
 
@@ -11,7 +11,7 @@ registerHooks({ resolve(specifier, context, nextResolve) {
11
11
  return { shortCircuit: true, url: new URL("./agent-coexistence-setup.ts", context.parentURL).href };
12
12
  return nextResolve(specifier, context);
13
13
  } });
14
- const { setupJokerAgents } = await import("./agent-coexistence-setup.js");
14
+ const { setupJokerAgents, switchAgentMode, inspectJokerAgents } = await import("./agent-coexistence-setup.js");
15
15
 
16
16
  async function fixture() {
17
17
  const root = await mkdtemp(join(tmpdir(), "osdy-joker-"));
@@ -30,6 +30,167 @@ const base = (f: Awaited<ReturnType<typeof fixture>>) => ({
30
30
  agentDir: f.agentDir, home: f.root, cwd: f.root, env: {},
31
31
  });
32
32
 
33
+ void test("off filters only Joker's extension and enables Gentle; on reverses, both idempotently", async () => {
34
+ const f = await fixture();
35
+ await writeFile(f.settingsPath, JSON.stringify({ packages: [...f.packages, { source: "npm:pi-subagents-j0k3r", skills: ["skills/*"], extensions: ["-other.ts"] }] }));
36
+ await switchAgentMode({ ...base(f), mode: "off" });
37
+ const off = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: Array<unknown>; osdyPiJokerExclusionOwned: boolean };
38
+ assert.equal(off.osdyPiJokerExclusionOwned, true);
39
+ assert.deepEqual(off.packages.at(-1), { source: "npm:pi-subagents-j0k3r", skills: ["skills/*"], extensions: ["-other.ts", "-./index.ts"] });
40
+ assert.deepEqual(off.packages.slice(1, 4), [
41
+ { source: f.gentle, extensions: ["-extensions/gentle-todo.ts"], themes: [], skills: ["skills/*"] },
42
+ "npm:gentle-pi@1.2.0", "npm:gentle-pi",
43
+ ]);
44
+ assert.equal((await inspectJokerAgents(base(f))).mode, "gentle");
45
+ assert.equal((await switchAgentMode({ ...base(f), mode: "off" })).changed, false);
46
+ await switchAgentMode({ ...base(f), mode: "on", install: () => Promise.reject(new Error("should not install")) });
47
+ assert.equal((await inspectJokerAgents(base(f))).mode, "joker");
48
+ assert.equal((await switchAgentMode({ ...base(f), mode: "on" })).changed, false);
49
+ const on = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: Array<unknown>; osdyPiJokerExclusionOwned?: boolean };
50
+ assert.equal(on.osdyPiJokerExclusionOwned, undefined);
51
+ assert.deepEqual(on.packages.at(-1), { source: "npm:pi-subagents-j0k3r", skills: ["skills/*"], extensions: ["-other.ts"] });
52
+ });
53
+
54
+ void test("off refuses without Gentle and unowned Joker filters without writes", async () => {
55
+ const f = await fixture();
56
+ await writeFile(f.settingsPath, '{"packages":["npm:pi-subagents-j0k3r"]}');
57
+ const original = await readFile(f.settingsPath, "utf8");
58
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "off" }), /Gentle/i);
59
+ assert.equal(await readFile(f.settingsPath, "utf8"), original);
60
+ await writeFile(f.settingsPath, '{"packages":["npm:gentle-pi",{"source":"npm:pi-subagents-j0k3r","extensions":["-./index.ts"]}]}');
61
+ const existing = await readFile(f.settingsPath, "utf8");
62
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "on" }), /unowned|ownership/i);
63
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "off" }), /unowned|ownership/i);
64
+ assert.equal(await readFile(f.settingsPath, "utf8"), existing);
65
+ });
66
+
67
+ void test("off enables Gentle without installing an absent Joker", async () => {
68
+ const f = await fixture();
69
+ const result = await switchAgentMode({ ...base(f), mode: "off", install: () => Promise.reject(new Error("must not install")) });
70
+ assert.equal(result.installed, false);
71
+ assert.equal((await inspectJokerAgents(base(f))).mode, "gentle");
72
+ const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[]; osdyPiJokerExclusionOwned?: boolean };
73
+ assert.equal(settings.osdyPiJokerExclusionOwned, undefined);
74
+ assert.equal(settings.packages.at(-1), "npm:gentle-pi");
75
+ });
76
+
77
+ void test("off restores string and object Gentle registrations without leaving empty filters", async () => {
78
+ const f = await fixture();
79
+ await writeFile(f.settingsPath, JSON.stringify({ theme: "dark", packages: [
80
+ { source: "npm:gentle-pi@1.2.0", extensions: ["-extensions/gentle-agents.ts"] },
81
+ { source: "npm:gentle-pi", skills: ["skills/*"], extensions: ["-extensions/gentle-agents.ts"] },
82
+ "npm:pi-subagents-j0k3r",
83
+ ] }));
84
+ await switchAgentMode({ ...base(f), mode: "off" });
85
+ const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { theme: string; packages: unknown[] };
86
+ assert.equal(settings.theme, "dark");
87
+ assert.deepEqual(settings.packages.slice(0, 2), [
88
+ "npm:gentle-pi@1.2.0",
89
+ { source: "npm:gentle-pi", skills: ["skills/*"] },
90
+ ]);
91
+ assert.equal((await inspectJokerAgents(base(f))).mode, "gentle");
92
+ });
93
+
94
+ void test("empty extension arrays disable selected agents, including Joker, without writes", async () => {
95
+ const f = await fixture();
96
+ for (const [mode, packages] of [
97
+ ["off", [{ source: "npm:gentle-pi", extensions: [] }, "npm:pi-subagents-j0k3r"]],
98
+ ["on", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", extensions: [] }]],
99
+ ] as const) {
100
+ await writeFile(f.settingsPath, JSON.stringify({ packages }));
101
+ const before = await readFile(f.settingsPath, "utf8");
102
+ assert.notEqual((await inspectJokerAgents(base(f))).mode, mode === "off" ? "gentle" : "joker");
103
+ await assert.rejects(switchAgentMode({ ...base(f), mode }), /allowlist/i);
104
+ assert.equal(await readFile(f.settingsPath, "utf8"), before);
105
+ }
106
+ });
107
+
108
+ void test("mode switches do not turn disabled extension arrays into all-but-agent filters", async () => {
109
+ const f = await fixture();
110
+ for (const [mode, packages] of [
111
+ ["on", [{ source: "npm:gentle-pi", extensions: [] }, "npm:pi-subagents-j0k3r", "npm:other"]],
112
+ ["off", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", extensions: [] }, "npm:other"]],
113
+ ] as const) {
114
+ await writeFile(f.settingsPath, JSON.stringify({ theme: "dark", packages }));
115
+ const before = await readFile(f.settingsPath, "utf8");
116
+ let installs = 0;
117
+ await assert.rejects(switchAgentMode({ ...base(f), mode, install: () => { installs++; return Promise.resolve(); } }), /disabled|empty|extensions/i);
118
+ assert.equal(installs, 0);
119
+ assert.equal(await readFile(f.settingsPath, "utf8"), before);
120
+ }
121
+ });
122
+
123
+ void test("autoload disabled and Pi exclusion patterns cannot select an agent", async () => {
124
+ const f = await fixture();
125
+ for (const [mode, packages] of [
126
+ ["off", [{ source: "npm:gentle-pi", autoload: false }, "npm:pi-subagents-j0k3r"]],
127
+ ["on", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", autoload: false }]],
128
+ ["off", [{ source: "npm:gentle-pi", extensions: ["extensions/gentle-agents.ts", "!extensions/gentle-agents.ts"] }, "npm:pi-subagents-j0k3r"]],
129
+ ["on", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", extensions: ["./index.ts", "!./index.ts"] }]],
130
+ ["on", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", extensions: ["./index.ts", "!*.ts"] }]],
131
+ ] as const) {
132
+ await writeFile(f.settingsPath, JSON.stringify({ packages }));
133
+ const before = await readFile(f.settingsPath, "utf8");
134
+ assert.notEqual((await inspectJokerAgents(base(f))).mode, mode === "off" ? "gentle" : "joker");
135
+ await assert.rejects(switchAgentMode({ ...base(f), mode }), /autoload|allowlist/i);
136
+ assert.equal(await readFile(f.settingsPath, "utf8"), before);
137
+ }
138
+ });
139
+
140
+ void test("prevalidates known failures before install and reports partial installation on later failure", async () => {
141
+ const f = await fixture();
142
+ let calls = 0;
143
+ const install = async () => { calls++; await writeFile(f.settingsPath, '{"packages":["npm:pi-subagents-j0k3r"]}'); };
144
+ await writeFile(f.settingsPath, '{"packages":["npm:gentle-pi"],"osdyPiJokerExclusionOwned":false}');
145
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "on", install }), /ownership|marker/i);
146
+ assert.equal(calls, 0);
147
+ await writeFile(f.settingsPath, '{"packages":["npm:gentle-pi"]}');
148
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "on", install: async () => {
149
+ await install();
150
+ await writeFile(f.settingsPath, "{bad");
151
+ } }), /Joker may have been installed.*agents status/is);
152
+ assert.equal(calls, 1);
153
+ });
154
+
155
+ void test("off retains remaining positive and negative Gentle filters", async () => {
156
+ const f = await fixture();
157
+ await writeFile(f.settingsPath, JSON.stringify({ packages: [
158
+ { source: "npm:gentle-pi", skills: ["skills/*"], extensions: ["extensions/gentle-agents.ts", "-extensions/gentle-todo.ts", "-extensions/gentle-agents.ts"] },
159
+ "npm:pi-subagents-j0k3r",
160
+ ] }));
161
+ await switchAgentMode({ ...base(f), mode: "off" });
162
+ const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[] };
163
+ assert.deepEqual(settings.packages[0], {
164
+ source: "npm:gentle-pi", skills: ["skills/*"], extensions: ["extensions/gentle-agents.ts", "-extensions/gentle-todo.ts"],
165
+ });
166
+ assert.equal((await inspectJokerAgents(base(f))).mode, "gentle");
167
+ });
168
+
169
+ void test("off validates project override and malformed filters before touching settings", async () => {
170
+ const f = await fixture();
171
+ const project = join(f.root, "project");
172
+ await mkdir(join(project, ".pi"), { recursive: true });
173
+ await writeFile(join(project, ".pi", "settings.json"), '{"packages":["npm:gentle-pi"]}');
174
+ await assert.rejects(switchAgentMode({ ...base(f), cwd: project, mode: "off" }), /project-local Gentle/i);
175
+ await writeFile(f.settingsPath, '{"packages":["npm:gentle-pi",{"source":"npm:pi-subagents-j0k3r","extensions":false}]}');
176
+ const previous = await readFile(f.settingsPath, "utf8");
177
+ await assert.rejects(switchAgentMode({ ...base(f), mode: "off" }), /extensions filter/i);
178
+ assert.equal(await readFile(f.settingsPath, "utf8"), previous);
179
+ });
180
+
181
+ void test("positive extension allowlists that omit selected agents fail closed", async () => {
182
+ const f = await fixture();
183
+ for (const [mode, packages] of [
184
+ ["on", ["npm:gentle-pi", { source: "npm:pi-subagents-j0k3r", extensions: ["./other.ts"] }]],
185
+ ["off", [{ source: "npm:gentle-pi", extensions: ["extensions/gentle-todo.ts"] }, "npm:pi-subagents-j0k3r"]],
186
+ ] as const) {
187
+ await writeFile(f.settingsPath, JSON.stringify({ packages }));
188
+ const previous = await readFile(f.settingsPath, "utf8");
189
+ await assert.rejects(switchAgentMode({ ...base(f), mode }), /allowlist/i);
190
+ assert.equal(await readFile(f.settingsPath, "utf8"), previous);
191
+ }
192
+ });
193
+
33
194
  void test("installs Joker then narrowly reconciles every Gentle entry using latest settings", async () => {
34
195
  const f = await fixture();
35
196
  let calls = 0;
@@ -9,6 +9,8 @@ import { fileURLToPath } from "node:url";
9
9
  const runFile = promisify(execFile);
10
10
  const JOKER = "npm:pi-subagents-j0k3r";
11
11
  const GENTLE_EXCLUSION = "-extensions/gentle-agents.ts";
12
+ const JOKER_EXCLUSION = "-./index.ts";
13
+ const OWNED = "osdyPiJokerExclusionOwned";
12
14
 
13
15
  type Settings = Record<string, unknown> & { packages?: unknown[] };
14
16
 
@@ -65,12 +67,6 @@ async function gentle(entry: unknown, settingsDir: string, home: string): Promis
65
67
  }
66
68
  }
67
69
 
68
- function validateGentleFilters(entry: unknown): void {
69
- if (!object(entry) || entry.extensions === undefined) return;
70
- if (!Array.isArray(entry.extensions) || !entry.extensions.every((value: unknown) => typeof value === "string"))
71
- throw new Error("Gentle extensions filter must be an array of strings.");
72
- }
73
-
74
70
  async function regularSettings(path: string): Promise<Settings> {
75
71
  if (!(await lstat(path)).isFile()) throw new Error("Personal settings.json must be a regular file, not a link.");
76
72
  let value: unknown;
@@ -118,19 +114,87 @@ async function installJoker(): Promise<void> {
118
114
  await runFile("pi", ["install", JOKER], { cwd: homedir(), maxBuffer: 4096 });
119
115
  }
120
116
 
121
- export async function inspectJokerAgents(options: Omit<SetupOptions, "install">): Promise<{ jokerInstalled: boolean; gentleCount: number; filtered: boolean }> {
117
+ export async function inspectJokerAgents(options: Omit<SetupOptions, "install">): Promise<{ jokerInstalled: boolean; gentleCount: number; filtered: boolean; mode: "joker" | "gentle" | "mixed" | "unavailable" }> {
122
118
  const target = await validateTarget(options);
123
119
  const settings = await regularSettings(join(target, "settings.json"));
124
120
  const packages = settings.packages ?? [];
121
+ validatePackages(packages);
125
122
  const flags = await Promise.all(packages.map((entry) => gentle(entry, target, options.home ?? homedir())));
123
+ const filtered = flags.every((isGentle, index) => !isGentle || hasFilter(packages[index], GENTLE_EXCLUSION));
124
+ const jokerInstalled = packages.some(joker);
125
+ const jokerDisabled = packages.some((entry) => joker(entry) && hasFilter(entry, JOKER_EXCLUSION));
126
+ const jokerAllowed = packages.some((entry) => joker(entry) && allowsAgent(entry, "./index.ts"));
127
+ const gentleAllowed = flags.every((flag, i) => !flag || allowsAgent(packages[i], "extensions/gentle-agents.ts"));
126
128
  return {
127
- jokerInstalled: packages.some(joker),
128
- gentleCount: flags.filter(Boolean).length,
129
- filtered: flags.every((isGentle, index) => !isGentle ||
130
- (object(packages[index]) && Array.isArray(packages[index].extensions) && packages[index].extensions.includes(GENTLE_EXCLUSION))),
129
+ jokerInstalled, gentleCount: flags.filter(Boolean).length, filtered,
130
+ mode: jokerInstalled && !jokerDisabled && jokerAllowed && filtered ? "joker" :
131
+ (!jokerInstalled || jokerDisabled) && flags.some(Boolean) && gentleAllowed && flags.every((flag, i) => !flag || !hasFilter(packages[i], GENTLE_EXCLUSION)) ? "gentle" :
132
+ jokerInstalled || flags.some(Boolean) ? "mixed" : "unavailable",
131
133
  };
132
134
  }
133
135
 
136
+ function hasFilter(entry: unknown, filter: string): boolean {
137
+ return object(entry) && Array.isArray(entry.extensions) && entry.extensions.includes(filter);
138
+ }
139
+
140
+ function validatePackages(packages: unknown[]): void {
141
+ if (packages.some((entry) => typeof entry !== "string" && (!object(entry) || typeof entry.source !== "string")))
142
+ throw new Error("Personal settings packages contain an invalid entry.");
143
+ if (packages.filter(joker).length > 1) throw new Error("Personal settings contain duplicate Joker entries.");
144
+ for (const entry of packages) {
145
+ if (object(entry) && entry.extensions !== undefined &&
146
+ (!Array.isArray(entry.extensions) || !entry.extensions.every((value: unknown) => typeof value === "string")))
147
+ throw new Error("Gentle extensions or Joker extensions filter must be an array of strings.");
148
+ }
149
+ }
150
+
151
+ function allowsAgent(entry: unknown, expected: string): boolean {
152
+ if (object(entry) && entry.autoload === false) return false;
153
+ if (!object(entry) || !Array.isArray(entry.extensions)) return true;
154
+ const filters = entry.extensions as string[];
155
+ if (!filters.length) return false;
156
+ // Pi applies plain includes, then ! globs, + exact paths, and finally - exact paths.
157
+ // Refuse uncertain glob matches rather than reporting a provider ready incorrectly.
158
+ const exact = (pattern: string) => pattern.replace(/^\.\//, "") === expected.replace(/^\.\//, "");
159
+ const exclusions = filters.filter((filter) => filter.startsWith("!") || filter.startsWith("-"));
160
+ if (exclusions.some((filter) => filter.slice(1).includes("*") || filter.slice(1).includes("?") || exact(filter.slice(1)))) return false;
161
+ const positives = filters.filter((filter) => !/^[!+-]/.test(filter));
162
+ return !positives.length || positives.some(exact) || filters.some((filter) => filter.startsWith("+") && exact(filter.slice(1)));
163
+ }
164
+
165
+ function assertAgentAllowlists(packages: unknown[], gentleFlags: boolean[], mode: "on" | "off"): void {
166
+ for (const [index, entry] of packages.entries()) {
167
+ // Adding a negative filter to [] makes Pi load every other extension.
168
+ if (((gentleFlags[index] && mode === "on") || (joker(entry) && mode === "off")) &&
169
+ object(entry) && Array.isArray(entry.extensions) && entry.extensions.length === 0)
170
+ throw new Error("Cannot exclude an agent from disabled extensions []; nothing changed.");
171
+ if (!(joker(entry) && mode === "on") && !(gentleFlags[index] && mode === "off")) continue;
172
+ const expected = joker(entry) ? "./index.ts" : "extensions/gentle-agents.ts";
173
+ // The mode switch removes only its own known exclusion before selecting this agent.
174
+ const ownedFilter = joker(entry) ? JOKER_EXCLUSION : GENTLE_EXCLUSION;
175
+ let selected = entry;
176
+ if (object(entry) && Array.isArray(entry.extensions) && entry.extensions.includes(ownedFilter)) {
177
+ const remaining = entry.extensions.filter((value: unknown) => value !== ownedFilter);
178
+ const updated = { ...entry };
179
+ if (remaining.length) updated.extensions = remaining;
180
+ else delete updated.extensions;
181
+ selected = updated;
182
+ }
183
+ if (!allowsAgent(selected, expected))
184
+ throw new Error(`Package autoload or extension allowlist excludes ${expected}; cannot guarantee selected agent mode.`);
185
+ }
186
+ }
187
+
188
+ async function saveSettings(path: string, settings: Settings): Promise<void> {
189
+ const temporary = join(dirname(path), `.settings.${randomUUID()}.tmp`);
190
+ try {
191
+ await writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, { flag: "wx", mode: 0o600 });
192
+ await rename(temporary, path);
193
+ } finally {
194
+ await unlink(temporary).catch(() => {});
195
+ }
196
+ }
197
+
134
198
  async function validateTarget(options: Omit<SetupOptions, "install">): Promise<string> {
135
199
  const home = resolve(options.home ?? homedir());
136
200
  const target = resolve(options.agentDir);
@@ -149,48 +213,76 @@ async function validateTarget(options: Omit<SetupOptions, "install">): Promise<s
149
213
  }
150
214
 
151
215
  export async function setupJokerAgents(options: SetupOptions): Promise<{ installed: boolean; changed: boolean; gentleCount: number }> {
216
+ return switchAgentMode({ ...options, mode: "on" });
217
+ }
218
+
219
+ export async function switchAgentMode(options: SetupOptions & { mode: "on" | "off" }): Promise<{ installed: boolean; changed: boolean; gentleCount: number }> {
152
220
  const target = await validateTarget(options);
153
221
  const path = join(target, "settings.json");
154
222
  const before = await regularSettings(path);
155
223
  const original = before.packages ?? [];
156
- if (original.some((entry) => entry !== null && typeof entry !== "string" && !object(entry)))
157
- throw new Error("Personal settings packages contain an invalid entry.");
224
+ validatePackages(original);
158
225
  const initialGentle = await Promise.all(original.map((entry) => gentle(entry, target, options.home ?? homedir())));
159
- if (original.filter(joker).length > 1) throw new Error("Personal settings contain duplicate Joker entries.");
160
226
  const alreadyJoker = original.some(joker);
161
- for (const [index, isGentle] of initialGentle.entries()) {
162
- if (isGentle) validateGentleFilters(original[index]);
163
- }
164
- const filtered = initialGentle.every((isGentle, index) => !isGentle ||
165
- (object(original[index]) && Array.isArray(original[index].extensions) && original[index].extensions.includes(GENTLE_EXCLUSION)));
166
- if (alreadyJoker && filtered) return { installed: false, changed: false, gentleCount: initialGentle.filter(Boolean).length };
167
- if (!alreadyJoker) await (options.install ?? installJoker)();
227
+ if (options.mode === "off" && !initialGentle.some(Boolean))
228
+ throw new Error("Gentle agents require an eligible personal Gentle package; nothing changed.");
229
+ if (before[OWNED] !== undefined && before[OWNED] !== true)
230
+ throw new Error("Invalid Joker filter ownership marker; nothing changed.");
231
+ if (before[OWNED] === true && !original.some((entry) => joker(entry) && hasFilter(entry, JOKER_EXCLUSION)))
232
+ throw new Error("Joker filter ownership marker does not match settings; nothing changed.");
233
+ if (original.some((entry) => joker(entry) && hasFilter(entry, JOKER_EXCLUSION)) && before[OWNED] !== true)
234
+ throw new Error("Joker extension has an unowned exclusion; cannot change agent mode.");
235
+ assertAgentAllowlists(original, initialGentle, options.mode);
236
+ // All failures known from the original settings must be checked before Pi install can mutate them.
237
+ let installAttempted = false;
238
+ try {
239
+ if (options.mode === "on" && !alreadyJoker) {
240
+ installAttempted = true;
241
+ await (options.install ?? installJoker)();
242
+ }
168
243
  // Install owns only Joker. Re-read the latest settings instead of replaying a stale snapshot.
169
244
  await assertNoProjectGentle(options.cwd, options.home ?? homedir());
170
245
  const latest = await regularSettings(path);
171
246
  const packages = latest.packages ?? [];
172
- if (!packages.some(joker)) throw new Error("Pi install did not register Joker in personal settings; no Gentle filters were changed.");
173
- if (packages.filter(joker).length > 1) throw new Error("Personal settings contain duplicate Joker entries.");
247
+ validatePackages(packages);
248
+ if (options.mode === "on" && !packages.some(joker)) throw new Error("Joker is not registered in personal settings; no filters were changed.");
249
+ if (latest[OWNED] !== undefined && latest[OWNED] !== true)
250
+ throw new Error("Invalid Joker filter ownership marker; nothing changed.");
251
+ if (packages.some((entry) => joker(entry) && hasFilter(entry, JOKER_EXCLUSION)) && latest[OWNED] !== true)
252
+ throw new Error("Joker extension has an unowned exclusion; cannot change agent mode.");
253
+ if (latest[OWNED] === true && !packages.some((entry) => joker(entry) && hasFilter(entry, JOKER_EXCLUSION)))
254
+ throw new Error("Joker filter ownership marker does not match settings; nothing changed.");
174
255
  const gentleFlags = await Promise.all(packages.map((entry) => gentle(entry, target, options.home ?? homedir())));
256
+ assertAgentAllowlists(packages, gentleFlags, options.mode);
257
+ if (options.mode === "off" && !gentleFlags.some(Boolean)) throw new Error("No eligible Gentle package; nothing changed.");
175
258
  let changed = false;
176
259
  const updated = packages.map((entry, index) => {
177
- if (!gentleFlags[index]) return entry;
178
- if (typeof entry !== "string" && !object(entry)) throw new Error("Gentle package entry is invalid.");
179
- const item = typeof entry === "string" ? { source: entry } : entry;
180
- validateGentleFilters(item);
260
+ const isGentle = gentleFlags[index];
261
+ if (!isGentle && !joker(entry)) return entry;
262
+ const filter = isGentle ? GENTLE_EXCLUSION : JOKER_EXCLUSION;
263
+ const shouldExclude = isGentle ? options.mode === "on" : options.mode === "off";
264
+ const item = typeof entry === "string" ? { source: entry } : entry as Record<string, unknown>;
181
265
  const extensions = item.extensions as string[] | undefined;
182
- if (extensions?.includes(GENTLE_EXCLUSION)) return entry;
266
+ if (shouldExclude === (extensions?.includes(filter) ?? false)) return entry;
183
267
  changed = true;
184
- return { ...item, extensions: [...(extensions ?? []), GENTLE_EXCLUSION] };
185
- });
186
- if (changed) {
187
- const temporary = join(target, `.settings.${randomUUID()}.tmp`);
188
- try {
189
- await writeFile(temporary, `${JSON.stringify({ ...latest, packages: updated }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
190
- await rename(temporary, path);
191
- } finally {
192
- await unlink(temporary).catch(() => {});
268
+ const next = shouldExclude ? [...(extensions ?? []), filter] : extensions!.filter((value) => value !== filter);
269
+ if (!next.length) {
270
+ const rest = { ...item };
271
+ delete rest.extensions;
272
+ return Object.keys(rest).length === 1 ? rest.source : rest;
193
273
  }
274
+ return { ...item, extensions: next };
275
+ });
276
+ const ownershipChanged = (latest[OWNED] === true) !== (options.mode === "off" && packages.some(joker));
277
+ if (changed || ownershipChanged) {
278
+ const saved: Settings = { ...latest, packages: updated };
279
+ if (options.mode === "off" && packages.some(joker)) saved[OWNED] = true;
280
+ else delete saved[OWNED];
281
+ await saveSettings(path, saved);
282
+ }
283
+ return { installed: options.mode === "on" && !alreadyJoker, changed: changed || ownershipChanged, gentleCount: gentleFlags.filter(Boolean).length };
284
+ } catch (error) {
285
+ if (!installAttempted) throw error;
286
+ throw new Error(`Joker may have been installed even though agent setup failed: ${error instanceof Error ? error.message : String(error)} Run /osdy-pi agents status, inspect personal Pi settings.json, and retry /osdy-pi agents on after resolving the issue.`, { cause: error });
194
287
  }
195
- return { installed: !alreadyJoker, changed, gentleCount: gentleFlags.filter(Boolean).length };
196
288
  }