@mirrowel/opencode-subagent-explorer 0.1.0-dev.1
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 +21 -0
- package/README.md +51 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +20 -0
- package/dist/palette-category.d.ts +30 -0
- package/dist/palette-category.js +72 -0
- package/dist/selfwire.d.ts +57 -0
- package/dist/selfwire.js +321 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +16 -0
- package/dist/sessions.d.ts +78 -0
- package/dist/sessions.js +299 -0
- package/dist/tui-host.d.ts +161 -0
- package/dist/tui-host.js +9 -0
- package/dist/tui.d.ts +24 -0
- package/dist/tui.js +1962 -0
- package/dist/v2-server.d.ts +10 -0
- package/dist/v2-server.js +14 -0
- package/dist/v2-tui.d.ts +24 -0
- package/dist/v2-tui.js +357 -0
- package/dist/v2-types.d.ts +270 -0
- package/dist/v2-types.js +10 -0
- package/dist/wizard.d.ts +67 -0
- package/dist/wizard.js +1580 -0
- package/package.json +97 -0
- package/src/index.ts +23 -0
- package/src/palette-category.ts +83 -0
- package/src/selfwire.ts +311 -0
- package/src/server.ts +17 -0
- package/src/sessions.ts +318 -0
- package/src/tui-host.ts +154 -0
- package/src/tui.tsx +75 -0
- package/src/v2-server.ts +17 -0
- package/src/v2-tui.ts +360 -0
- package/src/v2-types.ts +243 -0
- package/src/wizard.tsx +844 -0
- package/tsconfig.json +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agent Variants contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @mirrowel/opencode-subagent-explorer
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@mirrowel/opencode-subagent-explorer)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
An [OpenCode](https://opencode.ai) plugin for exploring — and cleaning up — the subagent sessions of your current session.
|
|
7
|
+
|
|
8
|
+
Subagent (task tool) sessions pile up fast: every task call spawns a child session with its own messages and history, including hidden background ones (Magic Context's historian passes, plugin-registered agents). They live in OpenCode's database even after the conversation moved on. This plugin gives you a tree view of all of them, per-session details, single deletion, and a one-key cleanup that removes every idle session in the current tree.
|
|
9
|
+
|
|
10
|
+
<!-- TODO screenshot: docs/screenshots/tree.png -->
|
|
11
|
+
|
|
12
|
+
## What it looks like
|
|
13
|
+
|
|
14
|
+
Open the palette and run **Subagent Explorer: Open** (or `/subagent-explorer`):
|
|
15
|
+
|
|
16
|
+
- The current session's full subagent tree — nested children included, sorted by creation time
|
|
17
|
+
- Per row: agent, title, running state (`▶` green while busy), last activity, `[hidden]` badge for background agents
|
|
18
|
+
- `i` — details: agent, status, times, message count, last model, first prompt
|
|
19
|
+
- `enter` — delete one session (running ones ask a red extra confirmation)
|
|
20
|
+
- `c` — cleanup: delete every idle subagent session in the tree, sparing running ones
|
|
21
|
+
- `r` — refresh · `s` — explore another session's tree
|
|
22
|
+
|
|
23
|
+
Deleting a session permanently removes it, its messages, and its history. The parent session keeps the task result text — only the "view subagent" jump target is lost.
|
|
24
|
+
|
|
25
|
+
<!-- TODO screenshot: docs/screenshots/details.png -->
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```jsonc
|
|
30
|
+
// ~/.config/opencode/opencode.json
|
|
31
|
+
{
|
|
32
|
+
"plugin": ["@mirrowel/opencode-subagent-explorer"]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The plugin wires its TUI side automatically (a matching `tui.json` entry is mirrored at the same config level). Works on OpenCode v1 and the v2 beta.
|
|
37
|
+
|
|
38
|
+
### With Config Studio
|
|
39
|
+
|
|
40
|
+
If you use [Config Studio](https://github.com/Mirrowel/opencode-config-studio), the explorer integrates into its **Tools** section instead — enable the module there and the standalone entry stands down.
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
- Sessions are listed via OpenCode's session API and walked by `parentID` from the current session (the TUI router's session; newest session as fallback)
|
|
45
|
+
- Running sessions are detected through the live active-session map — never guessed from timestamps
|
|
46
|
+
- Hidden/background classification: agents flagged `hidden` in your config, plus runtime-registered agents OpenCode doesn't expose
|
|
47
|
+
- All host calls are timeout-bounded and fail soft; a host without a session API gets a clear notice instead of a hang
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent Explorer server plugin (v1).
|
|
3
|
+
*
|
|
4
|
+
* The explorer is a TUI-driven tool: the server side exists purely so a
|
|
5
|
+
* single opencode.json registration loads the plugin (and self-wires the
|
|
6
|
+
* matching tui.json entry - see selfwire.ts). No hooks are registered; the
|
|
7
|
+
* plugin never intercepts runtime behavior.
|
|
8
|
+
*/
|
|
9
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
10
|
+
import { ensureTuiRegistration, isSubagentExplorerSpec } from "./selfwire.js";
|
|
11
|
+
export declare const plugin: Plugin;
|
|
12
|
+
export declare const __testInternals: {
|
|
13
|
+
ensureTuiRegistration: typeof ensureTuiRegistration;
|
|
14
|
+
isSubagentExplorerSpec: typeof isSubagentExplorerSpec;
|
|
15
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent Explorer server plugin (v1).
|
|
3
|
+
*
|
|
4
|
+
* The explorer is a TUI-driven tool: the server side exists purely so a
|
|
5
|
+
* single opencode.json registration loads the plugin (and self-wires the
|
|
6
|
+
* matching tui.json entry - see selfwire.ts). No hooks are registered; the
|
|
7
|
+
* plugin never intercepts runtime behavior.
|
|
8
|
+
*/
|
|
9
|
+
import { ensureTuiRegistration, isSubagentExplorerSpec } from "./selfwire.js";
|
|
10
|
+
export const plugin = async (input) => {
|
|
11
|
+
// File-scan only, wrapped in try/catch: never blocks or breaks startup.
|
|
12
|
+
try {
|
|
13
|
+
ensureTuiRegistration({ directory: input.directory, worktree: input.worktree, env: process.env });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
/* self-wiring is best-effort */
|
|
17
|
+
}
|
|
18
|
+
return {};
|
|
19
|
+
};
|
|
20
|
+
export const __testInternals = { ensureTuiRegistration, isSubagentExplorerSpec };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared palette category for Mirrowel OpenCode plugins loaded in the same
|
|
3
|
+
* session. Every family plugin declares its own label into a process-wide
|
|
4
|
+
* registry at TUI activation; a delayed reconciler joins all declared labels
|
|
5
|
+
* (alphabetical, deterministic) and writes the join into every registered
|
|
6
|
+
* command object, so the palette shows one combined section. A plugin loaded
|
|
7
|
+
* alone keeps its own name; third-party plugins never declare and stay out.
|
|
8
|
+
*
|
|
9
|
+
* Labels are defined only in each plugin's entry point - this module carries
|
|
10
|
+
* no per-plugin data. Keep the logic copies in sync across family repos.
|
|
11
|
+
*/
|
|
12
|
+
type CategorizedCommand = {
|
|
13
|
+
category?: string;
|
|
14
|
+
group?: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Declares this plugin's category label and attaches command objects whose
|
|
18
|
+
* `category` field is kept in sync. Returns the current join, so the very
|
|
19
|
+
* first registration already renders correctly even before reconcilers run.
|
|
20
|
+
*/
|
|
21
|
+
export declare function declarePaletteCategory(label: string, ...commands: CategorizedCommand[]): string;
|
|
22
|
+
/** Live join for callback-style command registration (reads fresh each call). */
|
|
23
|
+
export declare function currentPaletteCategory(): string;
|
|
24
|
+
/** Writes the current join into every declared command (idempotent). */
|
|
25
|
+
export declare function reconcilePaletteCategories(): void;
|
|
26
|
+
/** Delayed reconciles so later-activating siblings join before the user opens the palette. */
|
|
27
|
+
export declare function schedulePaletteReconcile(delaysMs?: readonly number[]): void;
|
|
28
|
+
/** Tests only: drop all declarations. */
|
|
29
|
+
export declare function __resetPaletteRegistry(): void;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared palette category for Mirrowel OpenCode plugins loaded in the same
|
|
3
|
+
* session. Every family plugin declares its own label into a process-wide
|
|
4
|
+
* registry at TUI activation; a delayed reconciler joins all declared labels
|
|
5
|
+
* (alphabetical, deterministic) and writes the join into every registered
|
|
6
|
+
* command object, so the palette shows one combined section. A plugin loaded
|
|
7
|
+
* alone keeps its own name; third-party plugins never declare and stay out.
|
|
8
|
+
*
|
|
9
|
+
* Labels are defined only in each plugin's entry point - this module carries
|
|
10
|
+
* no per-plugin data. Keep the logic copies in sync across family repos.
|
|
11
|
+
*/
|
|
12
|
+
const REGISTRY_KEY = Symbol.for("mirrowel.opencode.paletteCategory");
|
|
13
|
+
function registry() {
|
|
14
|
+
const globals = globalThis;
|
|
15
|
+
if (!globals[REGISTRY_KEY])
|
|
16
|
+
globals[REGISTRY_KEY] = { entries: [] };
|
|
17
|
+
return globals[REGISTRY_KEY];
|
|
18
|
+
}
|
|
19
|
+
function joinLabels(labels) {
|
|
20
|
+
if (labels.length <= 1)
|
|
21
|
+
return labels[0] ?? "";
|
|
22
|
+
return `${labels.slice(0, -1).join(", ")} & ${labels[labels.length - 1]}`;
|
|
23
|
+
}
|
|
24
|
+
function joinedCategory() {
|
|
25
|
+
const labels = [...new Set(registry().entries.map((entry) => entry.label))].sort((a, b) => a.localeCompare(b));
|
|
26
|
+
return joinLabels(labels);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Declares this plugin's category label and attaches command objects whose
|
|
30
|
+
* `category` field is kept in sync. Returns the current join, so the very
|
|
31
|
+
* first registration already renders correctly even before reconcilers run.
|
|
32
|
+
*/
|
|
33
|
+
export function declarePaletteCategory(label, ...commands) {
|
|
34
|
+
const reg = registry();
|
|
35
|
+
let entry = reg.entries.find((item) => item.label === label);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
entry = { label, commands: [] };
|
|
38
|
+
reg.entries.push(entry);
|
|
39
|
+
}
|
|
40
|
+
for (const command of commands) {
|
|
41
|
+
if (command && !entry.commands.includes(command))
|
|
42
|
+
entry.commands.push(command);
|
|
43
|
+
}
|
|
44
|
+
reconcilePaletteCategories();
|
|
45
|
+
return joinedCategory();
|
|
46
|
+
}
|
|
47
|
+
/** Live join for callback-style command registration (reads fresh each call). */
|
|
48
|
+
export function currentPaletteCategory() {
|
|
49
|
+
return joinedCategory();
|
|
50
|
+
}
|
|
51
|
+
/** Writes the current join into every declared command (idempotent). */
|
|
52
|
+
export function reconcilePaletteCategories() {
|
|
53
|
+
const category = joinedCategory();
|
|
54
|
+
for (const entry of registry().entries) {
|
|
55
|
+
for (const command of entry.commands) {
|
|
56
|
+
command.category = category;
|
|
57
|
+
command.group = category;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Delayed reconciles so later-activating siblings join before the user opens the palette. */
|
|
62
|
+
export function schedulePaletteReconcile(delaysMs = [2000, 6000]) {
|
|
63
|
+
for (const delay of delaysMs) {
|
|
64
|
+
const timer = setTimeout(() => reconcilePaletteCategories(), delay);
|
|
65
|
+
timer.unref?.();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Tests only: drop all declarations. */
|
|
69
|
+
export function __resetPaletteRegistry() {
|
|
70
|
+
;
|
|
71
|
+
globalThis[REGISTRY_KEY] = undefined;
|
|
72
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-wiring for the subagent-explorer TUI (v1 hosts only).
|
|
3
|
+
*
|
|
4
|
+
* OpenCode loads server plugins from the `plugin` array in opencode.json
|
|
5
|
+
* layers but TUI plugins only from tui.json layers. When subagent-explorer
|
|
6
|
+
* is registered standalone (opencode.json) without a matching tui.json
|
|
7
|
+
* entry, the explorer command never loads. ensureTuiRegistration() mirrors
|
|
8
|
+
* the server registration into tui.json AT THE SAME CONFIG LEVEL
|
|
9
|
+
* (global -> global, project -> project) with the same auto-correct
|
|
10
|
+
* semantics as Config Studio's selfwire:
|
|
11
|
+
*
|
|
12
|
+
* - identity matching: any checkout counts (repo folder named plain
|
|
13
|
+
* `subagent-explorer` or `opencode-subagent-explorer`), not just this
|
|
14
|
+
* running instance's directory
|
|
15
|
+
* - the tui entry must MATCH the server registration; local checkouts win
|
|
16
|
+
* over npm installs when both are registered
|
|
17
|
+
* - stale mirrors (tui entries at levels without a server registration) are
|
|
18
|
+
* removed; duplicates are deduped
|
|
19
|
+
*
|
|
20
|
+
* When Config Studio is registered anywhere (it can embed subagent-explorer
|
|
21
|
+
* and provides the Tools menu entry), selfwiring is skipped entirely so the
|
|
22
|
+
* two never fight over the TUI slot. v2 hosts never run this module: they
|
|
23
|
+
* auto-discover ./tui from server-declared plugins, and the v2 setup does
|
|
24
|
+
* not import it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isSubagentExplorerSpec(spec: unknown): spec is string;
|
|
27
|
+
export declare function isConfigStudioSpec(spec: unknown): spec is string;
|
|
28
|
+
export type WireResult = {
|
|
29
|
+
status: "skipped-studio";
|
|
30
|
+
} | {
|
|
31
|
+
status: "already-wired";
|
|
32
|
+
spec: string;
|
|
33
|
+
} | {
|
|
34
|
+
status: "wired";
|
|
35
|
+
spec: string;
|
|
36
|
+
target: string;
|
|
37
|
+
} | {
|
|
38
|
+
status: "corrected";
|
|
39
|
+
spec: string;
|
|
40
|
+
target: string;
|
|
41
|
+
} | {
|
|
42
|
+
status: "not-registered";
|
|
43
|
+
} | {
|
|
44
|
+
status: "failed";
|
|
45
|
+
error: string;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Mirrors the standalone subagent-explorer server registration into
|
|
49
|
+
* tui.json (same config level). No-op when Config Studio is registered
|
|
50
|
+
* anywhere - it embeds subagent-explorer and provides the Tools entry
|
|
51
|
+
* itself.
|
|
52
|
+
*/
|
|
53
|
+
export declare function ensureTuiRegistration(input?: {
|
|
54
|
+
directory?: string;
|
|
55
|
+
worktree?: string;
|
|
56
|
+
env?: NodeJS.ProcessEnv;
|
|
57
|
+
}): WireResult;
|
package/dist/selfwire.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-wiring for the subagent-explorer TUI (v1 hosts only).
|
|
3
|
+
*
|
|
4
|
+
* OpenCode loads server plugins from the `plugin` array in opencode.json
|
|
5
|
+
* layers but TUI plugins only from tui.json layers. When subagent-explorer
|
|
6
|
+
* is registered standalone (opencode.json) without a matching tui.json
|
|
7
|
+
* entry, the explorer command never loads. ensureTuiRegistration() mirrors
|
|
8
|
+
* the server registration into tui.json AT THE SAME CONFIG LEVEL
|
|
9
|
+
* (global -> global, project -> project) with the same auto-correct
|
|
10
|
+
* semantics as Config Studio's selfwire:
|
|
11
|
+
*
|
|
12
|
+
* - identity matching: any checkout counts (repo folder named plain
|
|
13
|
+
* `subagent-explorer` or `opencode-subagent-explorer`), not just this
|
|
14
|
+
* running instance's directory
|
|
15
|
+
* - the tui entry must MATCH the server registration; local checkouts win
|
|
16
|
+
* over npm installs when both are registered
|
|
17
|
+
* - stale mirrors (tui entries at levels without a server registration) are
|
|
18
|
+
* removed; duplicates are deduped
|
|
19
|
+
*
|
|
20
|
+
* When Config Studio is registered anywhere (it can embed subagent-explorer
|
|
21
|
+
* and provides the Tools menu entry), selfwiring is skipped entirely so the
|
|
22
|
+
* two never fight over the TUI slot. v2 hosts never run this module: they
|
|
23
|
+
* auto-discover ./tui from server-declared plugins, and the v2 setup does
|
|
24
|
+
* not import it.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { basename, dirname, join } from "node:path";
|
|
28
|
+
import { parse, stringify } from "comment-json";
|
|
29
|
+
const EXPLORER_NPM = "@mirrowel/opencode-subagent-explorer";
|
|
30
|
+
const STUDIO_NPM = "@mirrowel/opencode-config-studio";
|
|
31
|
+
function normalizePath(value) {
|
|
32
|
+
return value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
function specPathSegments(spec) {
|
|
35
|
+
if (!spec.startsWith("file:"))
|
|
36
|
+
return undefined;
|
|
37
|
+
try {
|
|
38
|
+
let url = spec;
|
|
39
|
+
if (!url.startsWith("file:///") && url.startsWith("file://"))
|
|
40
|
+
url = `file:///${url.slice("file://".length)}`;
|
|
41
|
+
return normalizePath(new URL(url).pathname);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function isLocalSpec(spec) {
|
|
48
|
+
return spec.startsWith("file:") || /^([a-zA-Z]:[\\/]|\/)/.test(spec);
|
|
49
|
+
}
|
|
50
|
+
export function isSubagentExplorerSpec(spec) {
|
|
51
|
+
if (typeof spec !== "string" || spec.length === 0)
|
|
52
|
+
return false;
|
|
53
|
+
if (spec === EXPLORER_NPM || spec.startsWith(`${EXPLORER_NPM}@`))
|
|
54
|
+
return true;
|
|
55
|
+
const path = specPathSegments(spec);
|
|
56
|
+
if (!path)
|
|
57
|
+
return false;
|
|
58
|
+
if (path.endsWith("/opencode-subagent-explorer") || path.endsWith("/subagent-explorer"))
|
|
59
|
+
return true;
|
|
60
|
+
// Cache/wrapper layouts: <base>/@mirrowel/opencode-subagent-explorer@<tag>[/node_modules/...]
|
|
61
|
+
return path.includes("/@mirrowel/opencode-subagent-explorer") || path.includes("/node_modules/@mirrowel/opencode-subagent-explorer");
|
|
62
|
+
}
|
|
63
|
+
export function isConfigStudioSpec(spec) {
|
|
64
|
+
if (typeof spec !== "string" || spec.length === 0)
|
|
65
|
+
return false;
|
|
66
|
+
if (spec === STUDIO_NPM || spec.startsWith(`${STUDIO_NPM}@`))
|
|
67
|
+
return true;
|
|
68
|
+
const path = specPathSegments(spec);
|
|
69
|
+
if (!path)
|
|
70
|
+
return false;
|
|
71
|
+
return path.endsWith("/opencode-config-studio");
|
|
72
|
+
}
|
|
73
|
+
function specStrings(data) {
|
|
74
|
+
const result = [];
|
|
75
|
+
for (const key of ["plugin", "plugins"]) {
|
|
76
|
+
const array = data[key];
|
|
77
|
+
if (!Array.isArray(array))
|
|
78
|
+
continue;
|
|
79
|
+
for (const entry of array) {
|
|
80
|
+
const spec = typeof entry === "string"
|
|
81
|
+
? entry
|
|
82
|
+
: Array.isArray(entry) && typeof entry[0] === "string"
|
|
83
|
+
? entry[0]
|
|
84
|
+
: entry && typeof entry === "object" && typeof entry.package === "string"
|
|
85
|
+
? entry.package
|
|
86
|
+
: undefined;
|
|
87
|
+
if (spec !== undefined)
|
|
88
|
+
result.push(spec);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
function readJsonc(path) {
|
|
94
|
+
try {
|
|
95
|
+
if (!existsSync(path))
|
|
96
|
+
return {};
|
|
97
|
+
return parse(readFileSync(path, "utf8"));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function sameDir(a, b) {
|
|
104
|
+
return normalizePath(a) === normalizePath(b);
|
|
105
|
+
}
|
|
106
|
+
function levelDirOf(configPath) {
|
|
107
|
+
let dir = dirname(configPath);
|
|
108
|
+
if (basename(dir) === ".opencode")
|
|
109
|
+
dir = dirname(dir);
|
|
110
|
+
return dir;
|
|
111
|
+
}
|
|
112
|
+
function globalConfigDir(env) {
|
|
113
|
+
if (env?.["OPENCODE_CONFIG_DIR"])
|
|
114
|
+
return env["OPENCODE_CONFIG_DIR"];
|
|
115
|
+
if (env?.["XDG_CONFIG_HOME"])
|
|
116
|
+
return join(env["XDG_CONFIG_HOME"], "opencode");
|
|
117
|
+
const home = process.env.USERPROFILE ?? process.env.HOME ?? "";
|
|
118
|
+
return join(home, ".config", "opencode");
|
|
119
|
+
}
|
|
120
|
+
function opencodeLayerPaths(input) {
|
|
121
|
+
const paths = [];
|
|
122
|
+
const pushDir = (dir) => {
|
|
123
|
+
for (const candidate of [join(dir, "opencode.json"), join(dir, "opencode.jsonc"), join(dir, ".opencode", "opencode.json"), join(dir, ".opencode", "opencode.jsonc")]) {
|
|
124
|
+
if (existsSync(candidate) && !paths.includes(candidate))
|
|
125
|
+
paths.push(candidate);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const globalDir = globalConfigDir(input.env);
|
|
129
|
+
if (input.env?.["OPENCODE_CONFIG"]) {
|
|
130
|
+
if (existsSync(input.env["OPENCODE_CONFIG"]))
|
|
131
|
+
paths.push(input.env["OPENCODE_CONFIG"]);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
pushDir(globalDir);
|
|
135
|
+
}
|
|
136
|
+
if (input.directory && input.worktree && input.directory.startsWith(input.worktree)) {
|
|
137
|
+
let current = input.directory;
|
|
138
|
+
for (let guard = 0; guard < 64 && current; guard++) {
|
|
139
|
+
pushDir(current);
|
|
140
|
+
if (current === input.worktree || current === dirname(current))
|
|
141
|
+
break;
|
|
142
|
+
current = dirname(current);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return paths;
|
|
146
|
+
}
|
|
147
|
+
function tuiLayerPaths(input) {
|
|
148
|
+
const paths = [];
|
|
149
|
+
const globalDir = globalConfigDir(input.env);
|
|
150
|
+
const globalTui = join(globalDir, "tui.json");
|
|
151
|
+
if (existsSync(globalTui))
|
|
152
|
+
paths.push(globalTui);
|
|
153
|
+
const envTui = input.env?.["OPENCODE_TUI_CONFIG"];
|
|
154
|
+
if (envTui && existsSync(envTui))
|
|
155
|
+
paths.push(envTui);
|
|
156
|
+
if (input.directory && input.worktree && input.directory.startsWith(input.worktree)) {
|
|
157
|
+
let current = input.directory;
|
|
158
|
+
for (let guard = 0; guard < 64 && current; guard++) {
|
|
159
|
+
for (const candidate of [join(current, "tui.json"), join(current, ".opencode", "tui.json")]) {
|
|
160
|
+
if (existsSync(candidate) && !paths.includes(candidate))
|
|
161
|
+
paths.push(candidate);
|
|
162
|
+
}
|
|
163
|
+
if (current === input.worktree || current === dirname(current))
|
|
164
|
+
break;
|
|
165
|
+
current = dirname(current);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return paths;
|
|
169
|
+
}
|
|
170
|
+
function ownIndices(tuiPath) {
|
|
171
|
+
const data = readJsonc(tuiPath);
|
|
172
|
+
const plugin = data["plugin"];
|
|
173
|
+
if (!Array.isArray(plugin))
|
|
174
|
+
return [];
|
|
175
|
+
const indices = [];
|
|
176
|
+
plugin.forEach((entry, index) => {
|
|
177
|
+
const spec = typeof entry === "string" ? entry : Array.isArray(entry) && typeof entry[0] === "string" ? entry[0] : undefined;
|
|
178
|
+
if (spec !== undefined && isSubagentExplorerSpec(spec))
|
|
179
|
+
indices.push(index);
|
|
180
|
+
});
|
|
181
|
+
return indices;
|
|
182
|
+
}
|
|
183
|
+
function preferLocal(specs) {
|
|
184
|
+
const local = specs.filter(isLocalSpec);
|
|
185
|
+
const npm = specs.filter((spec) => !local.includes(spec));
|
|
186
|
+
return [...local, ...npm];
|
|
187
|
+
}
|
|
188
|
+
function atomicWrite(path, content, stateDir) {
|
|
189
|
+
try {
|
|
190
|
+
const backupDir = join(stateDir, "selfwire-backups");
|
|
191
|
+
mkdirSync(backupDir, { recursive: true });
|
|
192
|
+
if (existsSync(path)) {
|
|
193
|
+
const backup = join(backupDir, `${basename(path)}.${Date.now()}.bak`);
|
|
194
|
+
writeFileSync(backup, readFileSync(path, "utf8"), "utf8");
|
|
195
|
+
}
|
|
196
|
+
const temp = `${path}.se-tmp`;
|
|
197
|
+
writeFileSync(temp, content, "utf8");
|
|
198
|
+
renameSync(temp, path);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Mirrors the standalone subagent-explorer server registration into
|
|
207
|
+
* tui.json (same config level). No-op when Config Studio is registered
|
|
208
|
+
* anywhere - it embeds subagent-explorer and provides the Tools entry
|
|
209
|
+
* itself.
|
|
210
|
+
*/
|
|
211
|
+
export function ensureTuiRegistration(input = {}) {
|
|
212
|
+
try {
|
|
213
|
+
const opencodeLayers = opencodeLayerPaths(input);
|
|
214
|
+
const tuiLayers = tuiLayerPaths(input);
|
|
215
|
+
// Config Studio present anywhere (server or tui layers) -> stand down.
|
|
216
|
+
for (const layer of [...opencodeLayers, ...tuiLayers]) {
|
|
217
|
+
if (specStrings(readJsonc(layer)).some((spec) => isConfigStudioSpec(spec))) {
|
|
218
|
+
return { status: "skipped-studio" };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const registrations = opencodeLayers
|
|
222
|
+
.map((path) => ({
|
|
223
|
+
path,
|
|
224
|
+
level: levelDirOf(path),
|
|
225
|
+
specs: specStrings(readJsonc(path)).filter((spec) => isSubagentExplorerSpec(spec)),
|
|
226
|
+
}))
|
|
227
|
+
.filter((layer) => layer.specs.length > 0);
|
|
228
|
+
if (registrations.length === 0)
|
|
229
|
+
return { status: "not-registered" };
|
|
230
|
+
const preferAt = (layers) => {
|
|
231
|
+
const ordered = [...layers].sort((a, b) => {
|
|
232
|
+
const aLocal = a.specs.some(isLocalSpec);
|
|
233
|
+
const bLocal = b.specs.some(isLocalSpec);
|
|
234
|
+
if (aLocal !== bLocal)
|
|
235
|
+
return aLocal ? -1 : 1;
|
|
236
|
+
return 0;
|
|
237
|
+
});
|
|
238
|
+
const first = ordered[0];
|
|
239
|
+
return first ? preferLocal(first.specs)[0] : undefined;
|
|
240
|
+
};
|
|
241
|
+
const wanted = preferAt(registrations);
|
|
242
|
+
if (!wanted)
|
|
243
|
+
return { status: "not-registered" };
|
|
244
|
+
const wantedLevel = registrations.find((layer) => layer.specs.includes(wanted))?.level ?? globalConfigDir(input.env);
|
|
245
|
+
const target = sameDir(wantedLevel, globalConfigDir(input.env))
|
|
246
|
+
? join(globalConfigDir(input.env), "tui.json")
|
|
247
|
+
: join(wantedLevel, "tui.json");
|
|
248
|
+
const stateDir = join(globalConfigDir(input.env), "subagent-explorer");
|
|
249
|
+
let wired = false;
|
|
250
|
+
let corrected = false;
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
const fixTuiFile = (tuiPath, level) => {
|
|
253
|
+
if (seen.has(tuiPath))
|
|
254
|
+
return;
|
|
255
|
+
seen.add(tuiPath);
|
|
256
|
+
const indices = ownIndices(tuiPath);
|
|
257
|
+
if (indices.length === 0)
|
|
258
|
+
return;
|
|
259
|
+
const levelLayers = registrations.filter((layer) => sameDir(layer.level, level));
|
|
260
|
+
const data = readJsonc(tuiPath);
|
|
261
|
+
const plugin = data["plugin"];
|
|
262
|
+
if (!Array.isArray(plugin))
|
|
263
|
+
return;
|
|
264
|
+
if (levelLayers.length === 0) {
|
|
265
|
+
// Stale mirror: no server registration at this level.
|
|
266
|
+
for (const index of [...indices].sort((a, b) => b - a))
|
|
267
|
+
plugin.splice(index, 1);
|
|
268
|
+
corrected = true;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const wantedHere = preferAt(levelLayers) ?? wanted;
|
|
272
|
+
const keepIndex = indices[0];
|
|
273
|
+
let changed = false;
|
|
274
|
+
if (indices.length > 1) {
|
|
275
|
+
for (const index of [...indices].slice(1).sort((a, b) => b - a))
|
|
276
|
+
plugin.splice(index, 1);
|
|
277
|
+
changed = true;
|
|
278
|
+
}
|
|
279
|
+
const entry = plugin[keepIndex];
|
|
280
|
+
const spec = typeof entry === "string" ? entry : Array.isArray(entry) && typeof entry[0] === "string" ? entry[0] : undefined;
|
|
281
|
+
if (spec !== wantedHere) {
|
|
282
|
+
if (Array.isArray(entry))
|
|
283
|
+
entry[0] = wantedHere;
|
|
284
|
+
else
|
|
285
|
+
plugin[keepIndex] = wantedHere;
|
|
286
|
+
changed = true;
|
|
287
|
+
}
|
|
288
|
+
if (changed)
|
|
289
|
+
corrected = true;
|
|
290
|
+
else
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (!atomicWrite(tuiPath, stringify(data, null, 2) + "\n", stateDir)) {
|
|
294
|
+
throw new Error(`failed to write ${tuiPath}`);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
for (const layer of tuiLayers)
|
|
298
|
+
fixTuiFile(layer, levelDirOf(layer));
|
|
299
|
+
// Ensure the registration level carries its mirror (independent of the
|
|
300
|
+
// correction pass above - an empty plugin array still needs the entry).
|
|
301
|
+
const targetIndices = existsSync(target) ? ownIndices(target) : [];
|
|
302
|
+
if (targetIndices.length === 0) {
|
|
303
|
+
const data = readJsonc(target);
|
|
304
|
+
const plugin = Array.isArray(data["plugin"]) ? data["plugin"] : [];
|
|
305
|
+
plugin.push(wanted);
|
|
306
|
+
data["plugin"] = plugin;
|
|
307
|
+
if (!atomicWrite(target, stringify(data, null, 2) + "\n", stateDir)) {
|
|
308
|
+
return { status: "failed", error: `failed to write ${target}` };
|
|
309
|
+
}
|
|
310
|
+
wired = true;
|
|
311
|
+
}
|
|
312
|
+
if (wired)
|
|
313
|
+
return { status: "wired", spec: wanted, target };
|
|
314
|
+
if (corrected)
|
|
315
|
+
return { status: "corrected", spec: wanted, target };
|
|
316
|
+
return { status: "already-wired", spec: wanted };
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
return { status: "failed", error: error instanceof Error ? error.message : String(error) };
|
|
320
|
+
}
|
|
321
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dual-target server entry.
|
|
3
|
+
*
|
|
4
|
+
* - OpenCode v1 resolves `exports["./server"]`, reads `mod.default`, and
|
|
5
|
+
* invokes the `server` factory (excess keys are never validated).
|
|
6
|
+
* - OpenCode v2 imports the same module, validates `mod.default` as
|
|
7
|
+
* `{ id, setup }`, and ignores the legacy `server` key - so one default
|
|
8
|
+
* export satisfies both loaders.
|
|
9
|
+
*/
|
|
10
|
+
declare const _default: {
|
|
11
|
+
id: string;
|
|
12
|
+
server: import("@opencode-ai/plugin").Plugin;
|
|
13
|
+
setup: import("./v2-types.js").V2ServerSetup;
|
|
14
|
+
};
|
|
15
|
+
export default _default;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { plugin } from "./index.js";
|
|
2
|
+
import { createV2ServerSetup } from "./v2-server.js";
|
|
3
|
+
/**
|
|
4
|
+
* Dual-target server entry.
|
|
5
|
+
*
|
|
6
|
+
* - OpenCode v1 resolves `exports["./server"]`, reads `mod.default`, and
|
|
7
|
+
* invokes the `server` factory (excess keys are never validated).
|
|
8
|
+
* - OpenCode v2 imports the same module, validates `mod.default` as
|
|
9
|
+
* `{ id, setup }`, and ignores the legacy `server` key - so one default
|
|
10
|
+
* export satisfies both loaders.
|
|
11
|
+
*/
|
|
12
|
+
export default {
|
|
13
|
+
id: "subagent-explorer",
|
|
14
|
+
server: plugin,
|
|
15
|
+
setup: createV2ServerSetup(),
|
|
16
|
+
};
|