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.
- package/LICENSE +5 -0
- package/README.md +54 -21
- package/extensions/osdy-pi/agent-coexistence-setup.test.ts +162 -1
- package/extensions/osdy-pi/agent-coexistence-setup.ts +130 -38
- package/extensions/osdy-pi/codex-usage-ui.ts +2 -32
- package/extensions/osdy-pi/locales/de.json +16 -0
- package/extensions/osdy-pi/locales/en.json +15 -0
- package/extensions/osdy-pi/locales/es.json +16 -0
- package/extensions/osdy-pi/locales/fr.json +16 -0
- package/extensions/osdy-pi/locales/pt-BR.json +16 -0
- package/extensions/osdy-pi/locales/pt.json +16 -0
- package/extensions/osdy-pi/locales/ru.json +16 -0
- package/extensions/osdy-pi/locales/uk.json +16 -0
- package/extensions/osdy-pi/locales/zh.json +16 -0
- package/extensions/osdy-pi/message-role-markers.test.ts +161 -0
- package/extensions/osdy-pi/message-role-markers.ts +52 -0
- package/extensions/osdy-pi/modal-frame.test.ts +78 -0
- package/extensions/osdy-pi/modal-frame.ts +35 -0
- package/extensions/osdy-pi/runtime.test.ts +56 -31
- package/extensions/osdy-pi/runtime.ts +47 -11
- package/extensions/osdy-pi/todo-command.test.ts +132 -0
- package/extensions/osdy-pi/todo-command.ts +60 -0
- package/extensions/osdy-pi/todo-config.test.ts +67 -0
- package/extensions/osdy-pi/todo-config.ts +78 -0
- package/extensions/osdy-pi/todo-domain.test.ts +106 -0
- package/extensions/osdy-pi/todo-domain.ts +183 -0
- package/extensions/osdy-pi/todo-i18n.test.ts +52 -0
- package/extensions/osdy-pi/todo-i18n.ts +43 -0
- package/extensions/osdy-pi/todo-panel.test.ts +163 -0
- package/extensions/osdy-pi/todo-panel.ts +88 -0
- package/extensions/osdy-pi/todo-session.test.ts +81 -0
- package/extensions/osdy-pi/todo-session.ts +75 -0
- package/extensions/osdy-pi/todo-tool-render.test.ts +63 -0
- package/extensions/osdy-pi/todo-tool-render.ts +60 -0
- package/extensions/osdy-pi/todo-tool.test.ts +139 -0
- package/extensions/osdy-pi/todo-tool.ts +104 -0
- package/extensions/osdy-pi/todo-widget.test.ts +235 -0
- package/extensions/osdy-pi/todo-widget.ts +111 -0
- package/extensions/osdy-pi/types.ts +1 -0
- package/extensions/osdy-pi/uninstall.test.ts +142 -0
- package/extensions/osdy-pi/uninstall.ts +141 -0
- package/extensions/osdy-pi/working-animation.test.ts +79 -14
- package/extensions/osdy-pi/working-animation.ts +8 -2
- package/package.json +11 -2
- package/themes/osdy-pi-catppuccin-frappe.json +1 -1
- package/themes/osdy-pi-catppuccin-latte.json +1 -1
- package/themes/osdy-pi-catppuccin-macchiato.json +1 -1
- package/themes/osdy-pi-catppuccin-mocha.json +1 -1
- package/themes/osdy-pi-dark.json +1 -1
- package/themes/osdy-pi-dracula.json +1 -1
- package/themes/osdy-pi-kanagawa-dragon.json +1 -1
- package/themes/osdy-pi-kanagawa-lotus.json +1 -1
- package/themes/osdy-pi-kanagawa-wave.json +1 -1
- package/themes/osdy-pi-lucent-orange.json +1 -1
- package/themes/osdy-pi-matrix.json +1 -1
- package/themes/osdy-pi-new.json +1 -1
- package/themes/osdy-pi-sexy.json +1 -1
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 |
|
|
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
|
|
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
|
|
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:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (
|
|
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
|
-
|
|
173
|
-
if (packages.
|
|
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
|
-
|
|
178
|
-
if (
|
|
179
|
-
const
|
|
180
|
-
|
|
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(
|
|
266
|
+
if (shouldExclude === (extensions?.includes(filter) ?? false)) return entry;
|
|
183
267
|
changed = true;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
}
|