osdy-pi 1.3.0 → 1.4.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/README.md CHANGED
@@ -99,6 +99,18 @@ git clone https://github.com/Gentleman-Programming/gentle-pi.git
99
99
  osdy-pi gentle setup "$(pwd)/gentle-pi"
100
100
  ```
101
101
 
102
+ ## Explicit Joker agents setup in normal Pi
103
+
104
+ After `pi install npm:osdy-pi`, open **normal personal Pi** (not `osdy`'s isolated profile) and run:
105
+
106
+ ```text
107
+ /osdy-pi agents setup
108
+ ```
109
+
110
+ 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
+
112
+ 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
+
102
114
  ## Gentle coexistence setup
103
115
 
104
116
  To load a local `gentle-pi` checkout while keeping Osdy Pi's UI authoritative, run:
@@ -0,0 +1,163 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { registerHooks } from "node:module";
7
+ import test from "node:test";
8
+
9
+ registerHooks({ resolve(specifier, context, nextResolve) {
10
+ if (specifier === "./agent-coexistence-setup.js" && context.parentURL?.endsWith(".test.ts"))
11
+ return { shortCircuit: true, url: new URL("./agent-coexistence-setup.ts", context.parentURL).href };
12
+ return nextResolve(specifier, context);
13
+ } });
14
+ const { setupJokerAgents } = await import("./agent-coexistence-setup.js");
15
+
16
+ async function fixture() {
17
+ const root = await mkdtemp(join(tmpdir(), "osdy-joker-"));
18
+ const agentDir = join(root, ".pi", "agent");
19
+ const gentle = join(root, "gentle");
20
+ await mkdir(agentDir, { recursive: true });
21
+ await mkdir(gentle);
22
+ await writeFile(join(gentle, "package.json"), '{"name":"gentle-pi"}');
23
+ const settingsPath = join(agentDir, "settings.json");
24
+ const packages = ["npm:other", { source: gentle, extensions: ["-extensions/gentle-todo.ts"], themes: [], skills: ["skills/*"] }, "npm:gentle-pi@1.2.0", { source: "npm:gentle-pi", extensions: ["-extensions/gentle-agents.ts"] }];
25
+ await writeFile(settingsPath, JSON.stringify({ theme: "dark", packages }));
26
+ return { root, agentDir, gentle, settingsPath, packages };
27
+ }
28
+
29
+ const base = (f: Awaited<ReturnType<typeof fixture>>) => ({
30
+ agentDir: f.agentDir, home: f.root, cwd: f.root, env: {},
31
+ });
32
+
33
+ void test("installs Joker then narrowly reconciles every Gentle entry using latest settings", async () => {
34
+ const f = await fixture();
35
+ let calls = 0;
36
+ const install = async () => {
37
+ calls++;
38
+ const latest = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[] };
39
+ latest.packages.push("npm:pi-subagents-j0k3r", "npm:newly-added");
40
+ await writeFile(f.settingsPath, JSON.stringify(latest));
41
+ };
42
+ await setupJokerAgents({ ...base(f), install });
43
+ const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[]; theme: string };
44
+ assert.equal(calls, 1);
45
+ assert.deepEqual(settings.packages, [
46
+ "npm:other",
47
+ { source: f.gentle, extensions: ["-extensions/gentle-todo.ts", "-extensions/gentle-agents.ts"], themes: [], skills: ["skills/*"] },
48
+ { source: "npm:gentle-pi@1.2.0", extensions: ["-extensions/gentle-agents.ts"] },
49
+ { source: "npm:gentle-pi", extensions: ["-extensions/gentle-agents.ts"] },
50
+ "npm:pi-subagents-j0k3r", "npm:newly-added",
51
+ ]);
52
+ assert.equal(settings.theme, "dark");
53
+ await setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("duplicate install")) });
54
+ assert.equal((await readFile(f.settingsPath, "utf8")).includes("duplicate"), false);
55
+ });
56
+
57
+ void test("relative Gentle sources resolve from settings and project overrides fail closed", async () => {
58
+ const f = await fixture();
59
+ const local = join(f.agentDir, "gentle-local");
60
+ await mkdir(local);
61
+ await writeFile(join(local, "package.json"), '{"name":"gentle-pi"}');
62
+ await writeFile(f.settingsPath, '{"packages":["./gentle-local","npm:pi-subagents-j0k3r"]}');
63
+ await setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("should skip")) });
64
+ const result = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: unknown[] };
65
+ assert.deepEqual(result.packages[0], { source: "./gentle-local", extensions: ["-extensions/gentle-agents.ts"] });
66
+ const project = join(f.root, "project");
67
+ await mkdir(join(project, ".pi", "gentle"), { recursive: true });
68
+ await writeFile(join(project, ".pi", "gentle", "package.json"), '{"name":"gentle-pi"}');
69
+ await writeFile(join(project, ".pi", "settings.json"), '{"packages":["./gentle"]}');
70
+ await assert.rejects(setupJokerAgents({ ...base(f), cwd: project }), /project-local Gentle/i);
71
+ });
72
+
73
+ void test("bare, tilde, and file URL local Gentle entries get the narrow personal filter", async () => {
74
+ const f = await fixture();
75
+ const bare = join(f.agentDir, "gentle");
76
+ await mkdir(bare);
77
+ await writeFile(join(bare, "package.json"), '{"name":"gentle-pi"}');
78
+ await writeFile(f.settingsPath, JSON.stringify({ packages: ["gentle", "~/gentle", pathToFileURL(f.gentle).href, "npm:pi-subagents-j0k3r"] }));
79
+ const result = await setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("no install")) });
80
+ assert.equal(result.gentleCount, 3);
81
+ const settings = JSON.parse(await readFile(f.settingsPath, "utf8")) as { packages: Array<{ source: string; extensions: string[] }> };
82
+ assert.deepEqual(settings.packages.slice(0, 3).map((entry) => entry.extensions), Array(3).fill(["-extensions/gentle-agents.ts"]));
83
+ });
84
+
85
+ void test("bare project Gentle override and ambiguous Gentle local path fail before install", async () => {
86
+ const f = await fixture();
87
+ const project = join(f.root, "project");
88
+ await mkdir(join(project, ".pi", "gentle"), { recursive: true });
89
+ await writeFile(join(project, ".pi", "gentle", "package.json"), '{"name":"gentle-pi"}');
90
+ await writeFile(join(project, ".pi", "settings.json"), '{"packages":["gentle"]}');
91
+ let calls = 0;
92
+ const install = () => { calls++; return Promise.resolve(); };
93
+ await assert.rejects(setupJokerAgents({ ...base(f), cwd: project, install }), /project-local Gentle/i);
94
+ await writeFile(join(project, ".pi", "settings.json"), '{"packages":[]}');
95
+ await writeFile(f.settingsPath, '{"packages":["gentle","npm:pi-subagents-j0k3r"]}');
96
+ await assert.rejects(setupJokerAgents({ ...base(f), install }), /Gentle.*resolve|Gentle.*source/i);
97
+ assert.equal(calls, 0);
98
+ });
99
+
100
+ void test("recognizable remote Gentle sources fail closed before install, but unrelated Git packages remain", async () => {
101
+ const f = await fixture();
102
+ let calls = 0;
103
+ const install = () => { calls++; return Promise.resolve(); };
104
+ for (const remote of [
105
+ "git:github.com/Gentleman-Programming/gentle-pi",
106
+ "https://github.com/Gentleman-Programming/gentle-pi.git",
107
+ ]) {
108
+ await writeFile(f.settingsPath, JSON.stringify({ packages: [remote, "git:github.com/owner/other-package"] }));
109
+ await assert.rejects(setupJokerAgents({ ...base(f), install }), /remote Gentle.*local.*npm/i);
110
+ const project = join(f.root, "project");
111
+ await mkdir(join(project, ".pi"), { recursive: true });
112
+ await writeFile(join(project, ".pi", "settings.json"), JSON.stringify({ packages: [remote] }));
113
+ await writeFile(f.settingsPath, '{"packages":["npm:pi-subagents-j0k3r"]}');
114
+ await assert.rejects(setupJokerAgents({ ...base(f), cwd: project, install }), /remote Gentle.*local.*npm/i);
115
+ await writeFile(join(project, ".pi", "settings.json"), '{"packages":[]}');
116
+ }
117
+ assert.equal(calls, 0);
118
+ await writeFile(f.settingsPath, '{"packages":["git:github.com/owner/other-package","npm:pi-subagents-j0k3r"]}');
119
+ const result = await setupJokerAgents({ ...base(f), install });
120
+ assert.equal(result.gentleCount, 0);
121
+ });
122
+
123
+ void test("invalid Gentle filters or duplicate Joker declarations block setup before further writes", async () => {
124
+ const f = await fixture();
125
+ let calls = 0;
126
+ const install = () => { calls++; return Promise.resolve(); };
127
+ await writeFile(f.settingsPath, '{"packages":[{"source":"npm:gentle-pi","extensions":false}]}');
128
+ await assert.rejects(setupJokerAgents({ ...base(f), install }), /Gentle extensions/i);
129
+ await writeFile(f.settingsPath, '{"packages":["npm:pi-subagents-j0k3r","npm:pi-subagents-j0k3r"]}');
130
+ await assert.rejects(setupJokerAgents({ ...base(f), install }), /duplicate Joker/i);
131
+ assert.equal(calls, 0);
132
+ });
133
+
134
+ void test("without Gentle, only Joker installation changes settings", async () => {
135
+ const f = await fixture();
136
+ await writeFile(f.settingsPath, '{"packages":["npm:other"]}');
137
+ await setupJokerAgents({ ...base(f), install: async () => {
138
+ await writeFile(f.settingsPath, '{"packages":["npm:other","npm:pi-subagents-j0k3r"]}');
139
+ } });
140
+ assert.deepEqual(JSON.parse(await readFile(f.settingsPath, "utf8")) as unknown, { packages: ["npm:other", "npm:pi-subagents-j0k3r"] });
141
+ });
142
+
143
+ void test("rejects alternate agent directories and project-local Gentle overrides before install", async () => {
144
+ const f = await fixture();
145
+ let calls = 0;
146
+ const install = () => { calls++; return Promise.resolve(); };
147
+ await assert.rejects(setupJokerAgents({ ...base(f), agentDir: join(f.root, "isolated"), install }), /personal|agent/i);
148
+ await assert.rejects(setupJokerAgents({ ...base(f), env: { PI_CODING_AGENT_DIR: f.agentDir }, install }), /override|normal/i);
149
+ const project = join(f.root, "project");
150
+ await mkdir(join(project, ".pi"), { recursive: true });
151
+ await writeFile(join(project, ".pi", "settings.json"), '{"packages":["npm:gentle-pi"]}');
152
+ await assert.rejects(setupJokerAgents({ ...base(f), cwd: project, install }), /project|Gentle/i);
153
+ assert.equal(calls, 0);
154
+ });
155
+
156
+ void test("failed installer or malformed latest settings cannot rewrite personal settings", async () => {
157
+ const f = await fixture();
158
+ const original = await readFile(f.settingsPath, "utf8");
159
+ await assert.rejects(setupJokerAgents({ ...base(f), install: () => Promise.reject(new Error("install failed")) }), /install failed/);
160
+ assert.equal(await readFile(f.settingsPath, "utf8"), original);
161
+ await assert.rejects(setupJokerAgents({ ...base(f), install: async () => { await writeFile(f.settingsPath, "{bad"); } }), /settings/i);
162
+ assert.equal(await readFile(f.settingsPath, "utf8"), "{bad");
163
+ });
@@ -0,0 +1,196 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstat, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const runFile = promisify(execFile);
10
+ const JOKER = "npm:pi-subagents-j0k3r";
11
+ const GENTLE_EXCLUSION = "-extensions/gentle-agents.ts";
12
+
13
+ type Settings = Record<string, unknown> & { packages?: unknown[] };
14
+
15
+ type SetupOptions = {
16
+ agentDir: string;
17
+ home?: string;
18
+ cwd: string;
19
+ env?: NodeJS.ProcessEnv;
20
+ install?: () => Promise<void>;
21
+ };
22
+
23
+ function object(value: unknown): value is Record<string, unknown> {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value);
25
+ }
26
+
27
+ function source(entry: unknown): string | undefined {
28
+ if (typeof entry === "string") return entry;
29
+ return object(entry) && typeof entry.source === "string" ? entry.source : undefined;
30
+ }
31
+
32
+ function npmGentle(entry: unknown): boolean {
33
+ return /^npm:gentle-pi(?:@[^\s]+)?$/i.test(source(entry) ?? "");
34
+ }
35
+
36
+ function joker(entry: unknown): boolean {
37
+ return /^npm:pi-subagents-j0k3r(?:@[^\s]+)?$/i.test(source(entry) ?? "");
38
+ }
39
+
40
+ async function gentle(entry: unknown, settingsDir: string, home: string): Promise<boolean> {
41
+ if (npmGentle(entry)) return true;
42
+ const value = source(entry);
43
+ if (!value || /^npm:/i.test(value)) return false;
44
+ if (/^(?:git:|https?:\/\/)/i.test(value)) {
45
+ if (/(?:^|\/)gentle-pi(?:\.git)?(?:[@#?][^\s]*)?$/i.test(value))
46
+ throw new Error("Remote Gentle source unsupported: register a local gentle-pi checkout or npm:gentle-pi in personal Pi, then rerun agents setup.");
47
+ return false;
48
+ }
49
+ let path: string;
50
+ try {
51
+ if (value.startsWith("file:")) path = fileURLToPath(value);
52
+ else if (value === "~" || value.startsWith("~/")) path = resolve(home, value.slice(value === "~" ? 1 : 2));
53
+ else if (!value.includes(":")) path = resolve(settingsDir, value);
54
+ else return false;
55
+ } catch {
56
+ throw new Error(`Gentle source cannot be resolved: ${value}`);
57
+ }
58
+ try {
59
+ const manifest: unknown = JSON.parse(await readFile(join(path, "package.json"), "utf8"));
60
+ return object(manifest) && manifest.name === "gentle-pi";
61
+ } catch {
62
+ if (/(?:^|[\\/])gentle(?:-pi)?$/i.test(path))
63
+ throw new Error(`Gentle source cannot be resolved: ${value}`);
64
+ return false;
65
+ }
66
+ }
67
+
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
+ async function regularSettings(path: string): Promise<Settings> {
75
+ if (!(await lstat(path)).isFile()) throw new Error("Personal settings.json must be a regular file, not a link.");
76
+ let value: unknown;
77
+ try {
78
+ value = JSON.parse(await readFile(path, "utf8"));
79
+ } catch {
80
+ throw new Error("Personal settings.json must contain valid JSON.");
81
+ }
82
+ if (!object(value) || (value.packages !== undefined && !Array.isArray(value.packages)))
83
+ throw new Error("Personal settings.json must have an object root and array packages.");
84
+ return value;
85
+ }
86
+
87
+ async function assertNoProjectGentle(cwd: string, home: string): Promise<void> {
88
+ let directory = resolve(cwd);
89
+ while (true) {
90
+ const local = join(directory, ".pi", "settings.json");
91
+ let text: string;
92
+ try {
93
+ text = await readFile(local, "utf8");
94
+ } catch (error) {
95
+ if (object(error) && error.code === "ENOENT") {
96
+ const parent = dirname(directory);
97
+ if (parent === directory) return;
98
+ directory = parent;
99
+ continue;
100
+ }
101
+ throw error;
102
+ }
103
+ let settings: unknown;
104
+ try { settings = JSON.parse(text); }
105
+ catch { throw new Error(`Project settings invalid at ${local}; cannot guarantee Gentle filtering.`); }
106
+ if (!object(settings) || (settings.packages !== undefined && !Array.isArray(settings.packages)))
107
+ throw new Error(`Project settings invalid at ${local}; cannot guarantee Gentle filtering.`);
108
+ for (const entry of settings.packages ?? []) {
109
+ if (await gentle(entry, dirname(local), home)) throw new Error(`Project-local Gentle package at ${local} can override the personal filter.`);
110
+ }
111
+ const parent = dirname(directory);
112
+ if (parent === directory) return;
113
+ directory = parent;
114
+ }
115
+ }
116
+
117
+ async function installJoker(): Promise<void> {
118
+ await runFile("pi", ["install", JOKER], { cwd: homedir(), maxBuffer: 4096 });
119
+ }
120
+
121
+ export async function inspectJokerAgents(options: Omit<SetupOptions, "install">): Promise<{ jokerInstalled: boolean; gentleCount: number; filtered: boolean }> {
122
+ const target = await validateTarget(options);
123
+ const settings = await regularSettings(join(target, "settings.json"));
124
+ const packages = settings.packages ?? [];
125
+ const flags = await Promise.all(packages.map((entry) => gentle(entry, target, options.home ?? homedir())));
126
+ 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))),
131
+ };
132
+ }
133
+
134
+ async function validateTarget(options: Omit<SetupOptions, "install">): Promise<string> {
135
+ const home = resolve(options.home ?? homedir());
136
+ const target = resolve(options.agentDir);
137
+ if (options.env?.PI_CODING_AGENT_DIR !== undefined || !isAbsolute(options.agentDir))
138
+ throw new Error("Agents setup requires normal personal Pi; agent directory override or isolated profile is unsupported.");
139
+ let personal: boolean;
140
+ try {
141
+ personal = (await realpath(target)) === (await realpath(join(home, ".pi", "agent")));
142
+ } catch {
143
+ personal = false;
144
+ }
145
+ if (!personal)
146
+ throw new Error("Agents setup requires the normal personal Pi agent directory, not an isolated profile.");
147
+ await assertNoProjectGentle(options.cwd, home);
148
+ return target;
149
+ }
150
+
151
+ export async function setupJokerAgents(options: SetupOptions): Promise<{ installed: boolean; changed: boolean; gentleCount: number }> {
152
+ const target = await validateTarget(options);
153
+ const path = join(target, "settings.json");
154
+ const before = await regularSettings(path);
155
+ 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.");
158
+ 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
+ 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)();
168
+ // Install owns only Joker. Re-read the latest settings instead of replaying a stale snapshot.
169
+ await assertNoProjectGentle(options.cwd, options.home ?? homedir());
170
+ const latest = await regularSettings(path);
171
+ 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.");
174
+ const gentleFlags = await Promise.all(packages.map((entry) => gentle(entry, target, options.home ?? homedir())));
175
+ let changed = false;
176
+ 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);
181
+ const extensions = item.extensions as string[] | undefined;
182
+ if (extensions?.includes(GENTLE_EXCLUSION)) return entry;
183
+ 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(() => {});
193
+ }
194
+ }
195
+ return { installed: !alreadyJoker, changed, gentleCount: gentleFlags.filter(Boolean).length };
196
+ }
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { registerHooks } from "node:module";
4
4
  import test from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import ts from "typescript";
8
8
 
9
9
  registerHooks({
@@ -43,6 +43,7 @@ const { PLUGIN_EVENTS, subscribeQuestionPromptAudioNotification } =
43
43
  const {
44
44
  createActiveSessionRefresh,
45
45
  getOsdyCommandCompletions,
46
+ handleAgentsSetupCommand,
46
47
  refreshCodexUsage,
47
48
  } = await import("./runtime.js");
48
49
 
@@ -80,6 +81,39 @@ class TestSessionContextProvider {
80
81
  }
81
82
  }
82
83
 
84
+ void test("agents setup requires interactive confirmation and reloads only after success", async () => {
85
+ const notices: string[] = [];
86
+ let installs = 0;
87
+ let reloads = 0;
88
+ let accepted = false;
89
+ const ui = {
90
+ confirm: (_title: string, message: string) => {
91
+ assert.match(message, /pi-subagents-j0k3r/);
92
+ assert.match(message, /gentle-agents\.ts/);
93
+ assert.match(message, /settings\.json/);
94
+ return Promise.resolve(accepted);
95
+ },
96
+ notify: (message: string) => { notices.push(message); },
97
+ };
98
+ const ctx = { mode: "tui", hasUI: true, cwd: "/tmp", ui, reload: () => { reloads++; return Promise.resolve(); } } as unknown as ExtensionCommandContext;
99
+ const setup = () => { installs++; return Promise.resolve({ installed: true, changed: true, gentleCount: 1 }); };
100
+ await handleAgentsSetupCommand("setup", ctx, setup);
101
+ assert.equal(installs, 0);
102
+ accepted = true;
103
+ await handleAgentsSetupCommand("setup", ctx, setup);
104
+ assert.equal(installs, 1);
105
+ assert.equal(reloads, 1);
106
+ await handleAgentsSetupCommand("status", ctx, setup, () => Promise.resolve({ jokerInstalled: true, gentleCount: 1, filtered: true }));
107
+ assert.ok(notices.some((notice) => notice.includes("agent exclusion complete")));
108
+ await handleAgentsSetupCommand("invalid", ctx, setup);
109
+ assert.equal(installs, 1);
110
+ assert.ok(notices.some((notice) => notice.includes("Usage:")));
111
+ assert.deepEqual(getOsdyCommandCompletions("agents"), [
112
+ { value: "agents setup", label: "agents setup" },
113
+ { value: "agents status", label: "agents status" },
114
+ ]);
115
+ });
116
+
83
117
  void test("header command completes only catalog choices and is persisted through session wiring", () => {
84
118
  assert.deepEqual(getOsdyCommandCompletions("header"), [
85
119
  { value: "header osdy-theme", label: "header osdy-theme" },
@@ -1,7 +1,11 @@
1
1
  import type {
2
2
  ExtensionAPI,
3
+ ExtensionCommandContext,
3
4
  ExtensionContext,
4
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { inspectJokerAgents, setupJokerAgents } from "./agent-coexistence-setup.js";
5
9
  import { createAudioEventRouter } from "./audio-event-router.js";
6
10
  import { registerAudioNotificationFlags } from "./audio-notification-config.js";
7
11
  import { createAudioNotificationService } from "./audio-notification-service.js";
@@ -250,6 +254,7 @@ export function getOsdyCommandCompletions(prefix: string) {
250
254
  "on",
251
255
  "off",
252
256
  "status",
257
+ "agents",
253
258
  "sound",
254
259
  "working-tree",
255
260
  "editor",
@@ -258,6 +263,9 @@ export function getOsdyCommandCompletions(prefix: string) {
258
263
  "diff",
259
264
  ].map((value) => ({ value, label: value }));
260
265
  }
266
+ if (trimmed === "agents") {
267
+ return ["agents setup", "agents status"].map((value) => ({ value, label: value }));
268
+ }
261
269
  if (trimmed === "sound") {
262
270
  return [{ value: "sound setup", label: "sound setup" }];
263
271
  }
@@ -302,6 +310,7 @@ export function getOsdyCommandCompletions(prefix: string) {
302
310
  "on",
303
311
  "off",
304
312
  "status",
313
+ "agents",
305
314
  "sound",
306
315
  "working-tree",
307
316
  "editor",
@@ -312,6 +321,12 @@ export function getOsdyCommandCompletions(prefix: string) {
312
321
  .filter((value) => value.startsWith(valuePrefix))
313
322
  .map((value) => ({ value, label: value }));
314
323
  }
324
+ if (parts[0] === "agents" && parts.length === 2) {
325
+ const valuePrefix = parts[1] ?? "";
326
+ return ["setup", "status"]
327
+ .filter((value) => value.startsWith(valuePrefix))
328
+ .map((value) => ({ value: `agents ${value}`, label: `agents ${value}` }));
329
+ }
315
330
  if (parts[0] === "sound" && parts.length === 2) {
316
331
  const valuePrefix = parts[1] ?? "";
317
332
  return ["setup"]
@@ -692,6 +707,49 @@ function registerUsageCommand(
692
707
  });
693
708
  }
694
709
 
710
+ export async function handleAgentsSetupCommand(
711
+ action: string | undefined,
712
+ ctx: ExtensionCommandContext,
713
+ setup: typeof setupJokerAgents = setupJokerAgents,
714
+ inspect: typeof inspectJokerAgents = inspectJokerAgents,
715
+ ): Promise<void> {
716
+ if (action !== "setup" && action !== "status") {
717
+ ctx.ui.notify("Usage: /osdy-pi agents setup|status", "warning");
718
+ return;
719
+ }
720
+ if (!ctx.hasUI) {
721
+ ctx.ui.notify("Agents setup requires an interactive confirmation.", "warning");
722
+ return;
723
+ }
724
+ const target = join(homedir(), ".pi", "agent");
725
+ if (action === "status") {
726
+ try {
727
+ const status = await inspect({ agentDir: target, cwd: ctx.cwd, env: process.env });
728
+ ctx.ui.notify(`Joker ${status.jokerInstalled ? "installed" : "not installed"}; ${status.gentleCount} Gentle entries; agent exclusion ${status.filtered ? "complete" : "incomplete"}.`, "info");
729
+ } catch (error) {
730
+ ctx.ui.notify(`Agents status unavailable: ${error instanceof Error ? error.message : "unknown error"}`, "warning");
731
+ }
732
+ return;
733
+ }
734
+ const confirmed = await ctx.ui.confirm(
735
+ "Set up Joker agents in normal Pi?",
736
+ `Target: ${join(target, "settings.json")}\nInstall npm:pi-subagents-j0k3r with Pi and exclude only -extensions/gentle-agents.ts from eligible Gentle packages. No isolated profile or credentials are changed.`,
737
+ );
738
+ if (!confirmed) return;
739
+ try {
740
+ const result = await setup({ agentDir: target, cwd: ctx.cwd, env: process.env });
741
+ ctx.ui.notify(`Joker agents ready (${result.gentleCount} Gentle entries checked). Reloading resources.`, "info");
742
+ } catch (error) {
743
+ ctx.ui.notify(`Agents setup failed: ${error instanceof Error ? error.message : "unknown error"}. Check settings and retry after resolving the error.`, "error");
744
+ return;
745
+ }
746
+ try {
747
+ await ctx.reload();
748
+ } catch {
749
+ ctx.ui.notify("Agents setup saved, but reload failed; restart Pi to apply the changes.", "warning");
750
+ }
751
+ }
752
+
695
753
  function registerCommand(
696
754
  pi: ExtensionAPI,
697
755
  state: OsdyState,
@@ -709,6 +767,10 @@ function registerCommand(
709
767
  getArgumentCompletions: getOsdyCommandCompletions,
710
768
  handler: async (args, ctx) => {
711
769
  const [action = "status", ...rest] = parseCommandArgs(args);
770
+ if (action === "agents") {
771
+ await handleAgentsSetupCommand(rest.length === 1 ? rest[0] : undefined, ctx);
772
+ return;
773
+ }
712
774
  if (["enable", "on"].includes(action)) {
713
775
  await enableOsdyPi(
714
776
  pi,
@@ -797,7 +859,7 @@ function registerCommand(
797
859
  return;
798
860
  }
799
861
  ctx.ui.notify(
800
- `Usage: /osdy-pi enable|disable|on|off|status | mascot ${MASCOT_CHOICES.join("|")}|status | header ${HEADER_VARIANT_CHOICES.join("|")}|status | editor auto|extended|simple|on|off|toggle|status | sound setup | working-tree ... | diff`,
862
+ `Usage: /osdy-pi agents setup|status | enable|disable|on|off|status | mascot ${MASCOT_CHOICES.join("|")}|status | header ${HEADER_VARIANT_CHOICES.join("|")}|status | editor auto|extended|simple|on|off|toggle|status | sound setup | working-tree ... | diff`,
801
863
  "warning",
802
864
  );
803
865
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osdy-pi",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Osdy Pi package: themes, header, and custom editor for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",