@krtclcdy/tersio 2.24.1 → 2.25.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/README.md +27 -21
- package/cli/banner.js +7 -4
- package/cli/common.js +14 -13
- package/cli/currency.js +1 -2
- package/cli/dashboard.js +57 -14
- package/cli/doctor-fix.js +88 -21
- package/cli/doctor.js +32 -24
- package/cli/hosts.js +19 -18
- package/cli/install.js +123 -52
- package/cli/interactive.js +29 -19
- package/cli/manifest.js +7 -0
- package/cli/profile.js +42 -11
- package/cli/rtk-wiring.js +28 -26
- package/cli/settings.js +37 -8
- package/cli/uninstall.js +40 -7
- package/cli/update.js +22 -14
- package/cli/usage.js +39 -14
- package/dashboard/dist/index.html +16 -16
- package/extensions/ai-addons-updater/index.js +69 -15
- package/extensions/ai-addons-updater/index.ts +60 -17
- package/extensions/caveman-session/index.js +28 -55
- package/extensions/caveman-session/index.ts +28 -55
- package/extensions/combo-toggle/index.js +40 -58
- package/extensions/combo-toggle/index.ts +38 -67
- package/extensions/lib/utils.js +60 -15
- package/extensions/lib/utils.ts +59 -15
- package/extensions/opencode/index.js +104 -0
- package/extensions/opencode/server.js +190 -0
- package/extensions/opencode/server.ts +200 -0
- package/extensions/rtk-session/index.js +20 -35
- package/extensions/rtk-session/index.ts +19 -34
- package/extensions/shared/carbon.js +1 -2
- package/extensions/shared/carbon.ts +1 -2
- package/extensions/shared/host.js +95 -46
- package/extensions/shared/host.ts +106 -50
- package/extensions/shared/plugin-settings.js +4 -6
- package/extensions/shared/plugin-settings.ts +4 -6
- package/extensions/shared/pricing.js +79 -25
- package/extensions/shared/pricing.ts +80 -26
- package/extensions/shared/rtk-gain.js +3 -5
- package/extensions/shared/rtk-gain.ts +3 -5
- package/extensions/shared/session-state.js +10 -33
- package/extensions/shared/session-state.ts +12 -38
- package/extensions/shared/status.js +38 -0
- package/extensions/shared/status.ts +45 -0
- package/extensions/shared/types.js +1 -2
- package/extensions/shared/types.ts +2 -2
- package/extensions/shared/usage-ledger.js +190 -56
- package/extensions/shared/usage-ledger.ts +194 -58
- package/extensions/shared/usage-store.js +228 -64
- package/extensions/shared/usage-store.ts +235 -67
- package/extensions/tersio-commands/index.js +4 -9
- package/extensions/tersio-commands/index.ts +4 -9
- package/package.json +8 -2
- package/tersio.js +3 -3
- package/cli/agent-hosts.js +0 -42
- package/cli/agents.js +0 -266
- package/cli/layer.js +0 -22
- package/cli/omp-layer.js +0 -51
- package/cli/pi-layer.js +0 -37
- package/cli/pi-wiring.js +0 -92
- package/extensions/shared/omp-prompt.js +0 -12
package/extensions/lib/utils.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Shared helpers for the installer and the AI add-ons updater. Node built-ins
|
|
2
|
-
// only — this module must stay dependency-free.
|
|
1
|
+
// Shared helpers for the installer and the AI add-ons updater. Node built-ins only — this module must stay dependency-free.
|
|
3
2
|
|
|
4
3
|
import { createHash } from 'node:crypto';
|
|
5
4
|
import { accessSync, constants, createWriteStream, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
@@ -21,6 +20,12 @@ export function withResolvers<T>(): PromiseWithResolvers<T> {
|
|
|
21
20
|
return { promise, resolve, reject };
|
|
22
21
|
}
|
|
23
22
|
|
|
23
|
+
// os.homedir() can ignore a runtime HOME, so a redirected home would read the real one.
|
|
24
|
+
export function homeDir(): string {
|
|
25
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
26
|
+
return home && home.trim() !== '' ? home : os.homedir();
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
|
|
25
30
|
export const CAVEMAN_REMOTE_RULE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/caveman/SKILL.md';
|
|
26
31
|
|
|
@@ -47,10 +52,13 @@ const RTK_PLATFORM_SPECS: Record<string, RtkPlatformSpec> = {
|
|
|
47
52
|
'darwin/x64': { triple: 'x86_64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
|
|
48
53
|
'darwin/arm64': { triple: 'aarch64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
|
|
49
54
|
};
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
|
|
56
|
+
// First executable `name` on PATH, else the managed Bun bin dir.
|
|
57
|
+
function firstExecutable(name: string, managedDir: string | null): string | null {
|
|
58
|
+
const ext = process.platform === 'win32' ? '.cmd' : '';
|
|
59
|
+
const candidates = (process.env.PATH || '').split(path.delimiter).filter(Boolean)
|
|
60
|
+
.flatMap((dir) => [path.join(dir, name + ext), path.join(dir, name)]);
|
|
61
|
+
if (managedDir) candidates.push(path.join(managedDir, name + ext), path.join(managedDir, name));
|
|
54
62
|
for (const candidate of candidates) {
|
|
55
63
|
try {
|
|
56
64
|
if (!statSync(candidate).isFile()) continue;
|
|
@@ -61,6 +69,15 @@ export function resolveRtkBinary(managedDir = path.join(os.homedir(), '.bun', 'b
|
|
|
61
69
|
return null;
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
export function resolveRtkBinary(managedDir = path.join(homeDir(), '.bun', 'bin')): string | null {
|
|
73
|
+
return firstExecutable(process.platform === 'win32' ? 'rtk.exe' : 'rtk', managedDir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A host binary to scan for its subagent marker.
|
|
77
|
+
export function resolveHostBinary(name: string, managedDir = path.join(homeDir(), '.bun', 'bin')): string | null {
|
|
78
|
+
return firstExecutable(name, managedDir);
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
export function rtkPlatformSpec(platform: string = process.platform, arch: string = process.arch): RtkPlatformSpec | null {
|
|
65
82
|
return RTK_PLATFORM_SPECS[`${platform}/${arch}`] || null;
|
|
66
83
|
}
|
|
@@ -73,24 +90,25 @@ function redirectNext(res: { statusCode?: number; headers: { location?: string }
|
|
|
73
90
|
return null;
|
|
74
91
|
}
|
|
75
92
|
|
|
76
|
-
// pi sets PI_CODING_AGENT; OMP does not. OMP does read PI_CODING_AGENT_DIR as
|
|
77
|
-
// its own relocation variable, so the override only counts under that marker.
|
|
93
|
+
// pi sets PI_CODING_AGENT; OMP does not. OMP does read PI_CODING_AGENT_DIR as its own relocation variable, so the override only counts under that marker.
|
|
78
94
|
export function isPiProcess(): boolean {
|
|
79
95
|
return process.env.PI_CODING_AGENT === 'true';
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
export function piAgentDir(): string {
|
|
83
99
|
const override = process.env.PI_CODING_AGENT_DIR;
|
|
84
|
-
return isPiProcess() && override ? override : path.join(
|
|
100
|
+
return isPiProcess() && override ? override : path.join(homeDir(), '.pi', 'agent');
|
|
85
101
|
}
|
|
86
102
|
|
|
87
|
-
// Ponytail is a Tersio dependency that npm hoists, so it can sit in Tersio's
|
|
88
|
-
// node_modules, a host's plugin dir, or beside it. Walking up covers all.
|
|
103
|
+
// Ponytail is a Tersio dependency that npm hoists, so it can sit in Tersio's node_modules, a host's plugin dir, or beside it. Walking up covers all.
|
|
89
104
|
export function findHoistedPackage(pkg: string, fromDir: string, ...relInside: string[]): string | null {
|
|
90
105
|
let dir = fromDir;
|
|
91
106
|
for (;;) {
|
|
92
107
|
const candidate = path.join(dir, 'node_modules', ...pkg.split('/'), ...relInside);
|
|
93
108
|
if (existsSync(candidate)) return candidate;
|
|
109
|
+
// pi installs npm packages under ~/.pi/agent/npm/node_modules.
|
|
110
|
+
const piCandidate = path.join(dir, 'npm', 'node_modules', ...pkg.split('/'), ...relInside);
|
|
111
|
+
if (existsSync(piCandidate)) return piCandidate;
|
|
94
112
|
const parent = path.dirname(dir);
|
|
95
113
|
if (parent === dir) return null;
|
|
96
114
|
dir = parent;
|
|
@@ -200,15 +218,41 @@ export async function readTextIfExists(p: string): Promise<string | null> {
|
|
|
200
218
|
try { return await fs.readFile(p, 'utf8'); } catch { return null; }
|
|
201
219
|
}
|
|
202
220
|
|
|
203
|
-
//
|
|
204
|
-
|
|
221
|
+
// Chunked so a 200 MiB host binary is never held in memory. `tail` keeps the last needle.length-1 bytes of the previous chunk, because a match can begin near the end of one chunk and finish inside the next.
|
|
222
|
+
export async function fileContains(filePath: string, needleText: string): Promise<boolean> {
|
|
223
|
+
const needle = Buffer.from(needleText, 'utf8');
|
|
224
|
+
if (needle.length === 0) return false;
|
|
225
|
+
const keep = needle.length - 1;
|
|
226
|
+
let handle;
|
|
227
|
+
try {
|
|
228
|
+
handle = await fs.open(filePath, 'r');
|
|
229
|
+
const { size } = await handle.stat();
|
|
230
|
+
const chunk = Buffer.allocUnsafe(1 << 20);
|
|
231
|
+
let tail = Buffer.alloc(0);
|
|
232
|
+
for (let position = 0; position < size;) {
|
|
233
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
|
|
234
|
+
if (bytesRead === 0) break;
|
|
235
|
+
const window = Buffer.concat([tail, chunk.subarray(0, bytesRead)]);
|
|
236
|
+
if (window.includes(needle)) return true;
|
|
237
|
+
tail = window.subarray(Math.max(0, window.length - keep));
|
|
238
|
+
position += bytesRead;
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
} finally {
|
|
244
|
+
await handle?.close().catch(() => { });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Tersio-owned data home: ~/.tersio. First use moves legacy ~/.omp/plugins/tersio-* files over so existing installs keep their history.
|
|
205
249
|
const migratedTersioFiles = new Set<string>();
|
|
206
250
|
|
|
207
251
|
export function tersioHome(): string {
|
|
208
252
|
// Matches the other TERSIO_* overrides so a test never reads the real home.
|
|
209
253
|
const override = process.env.TERSIO_HOME;
|
|
210
254
|
if (override && override.trim() !== '') return override;
|
|
211
|
-
return path.join(
|
|
255
|
+
return path.join(homeDir(), '.tersio');
|
|
212
256
|
}
|
|
213
257
|
|
|
214
258
|
|
|
@@ -218,7 +262,7 @@ export function tersioDataPath(name: string, legacy: string): string {
|
|
|
218
262
|
migratedTersioFiles.add(name);
|
|
219
263
|
try {
|
|
220
264
|
if (!existsSync(dest)) {
|
|
221
|
-
const src = path.join(
|
|
265
|
+
const src = path.join(homeDir(), '.omp', 'plugins', legacy);
|
|
222
266
|
if (existsSync(src)) {
|
|
223
267
|
mkdirSync(tersioHome(), { recursive: true });
|
|
224
268
|
renameSync(src, dest);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// OpenCode plugin entry: maps its plugin API onto the shared ExtensionApi the mode extensions consume.
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { Plugin } from '@opencode/plugin';
|
|
4
|
+
import cavemanSessionExtension from '../caveman-session/index.js';
|
|
5
|
+
import rtkSessionExtension from '../rtk-session/index.js';
|
|
6
|
+
import comboToggleExtension from '../combo-toggle/index.js';
|
|
7
|
+
import tersioCommandsExtension from '../tersio-commands/index.js';
|
|
8
|
+
export default Plugin.define({
|
|
9
|
+
id: 'tersio',
|
|
10
|
+
async setup(ctx) {
|
|
11
|
+
const entries = (await ctx.storage.get('entries')) ?? [];
|
|
12
|
+
const persist = () => { void ctx.storage.set('entries', entries).catch(() => { }); };
|
|
13
|
+
const stubCtx = () => ({
|
|
14
|
+
hasUI: false,
|
|
15
|
+
cwd: ctx.location.directory,
|
|
16
|
+
sessionManager: { getBranch: () => entries, getEntries: () => entries },
|
|
17
|
+
});
|
|
18
|
+
const commands = new Map();
|
|
19
|
+
const tools = new Map();
|
|
20
|
+
const beforeStart = [];
|
|
21
|
+
const inputHandlers = [];
|
|
22
|
+
const lifecycle = new Map();
|
|
23
|
+
const host = {
|
|
24
|
+
hostId: 'opencode',
|
|
25
|
+
setLabel() { },
|
|
26
|
+
registerCommand(name, config) { commands.set(name, config); },
|
|
27
|
+
registerTool(tool) { tools.set(tool.name, tool); },
|
|
28
|
+
appendEntry(type, data) { entries.push({ type: 'custom', customType: type, data }); persist(); },
|
|
29
|
+
exec: (cmd, args, opts) => new Promise((resolve) => {
|
|
30
|
+
execFile(cmd, args, { signal: opts?.signal, cwd: opts?.cwd }, (error, stdout, stderr) => {
|
|
31
|
+
const code = error && typeof error.code === 'number' ? error.code : 0;
|
|
32
|
+
resolve({ stdout: stdout ?? '', stderr: stderr ?? '', code, killed: !!error?.killed });
|
|
33
|
+
});
|
|
34
|
+
}),
|
|
35
|
+
cwd: ctx.location.directory,
|
|
36
|
+
on: (event, handler) => {
|
|
37
|
+
if (event === 'before_agent_start') {
|
|
38
|
+
beforeStart.push(handler);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (event === 'input') {
|
|
42
|
+
inputHandlers.push(handler);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const list = lifecycle.get(event) ?? [];
|
|
46
|
+
list.push(handler);
|
|
47
|
+
lifecycle.set(event, list);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
for (const extension of [cavemanSessionExtension, rtkSessionExtension, comboToggleExtension, tersioCommandsExtension])
|
|
51
|
+
extension(host);
|
|
52
|
+
// OpenCode has no session_start event surfaced to plugins, so restore once at setup.
|
|
53
|
+
for (const handler of lifecycle.get('session_start') ?? [])
|
|
54
|
+
await handler({}, stubCtx());
|
|
55
|
+
await ctx.command.transform((editor) => {
|
|
56
|
+
for (const [name, config] of commands) {
|
|
57
|
+
editor.add({
|
|
58
|
+
name,
|
|
59
|
+
description: config.description,
|
|
60
|
+
execute: async (input) => {
|
|
61
|
+
const text = typeof input.prompt?.text === 'string' ? input.prompt.text : '';
|
|
62
|
+
const args = text.includes(' ') ? text.slice(text.indexOf(' ') + 1).trim() : '';
|
|
63
|
+
await config.handler(args, stubCtx());
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
await ctx.tool.transform((editor) => {
|
|
69
|
+
for (const tool of tools.values()) {
|
|
70
|
+
editor.add({
|
|
71
|
+
name: tool.name,
|
|
72
|
+
description: tool.description,
|
|
73
|
+
input: tool.parameters,
|
|
74
|
+
execute: async (input) => {
|
|
75
|
+
const args = Array.isArray(input.args) ? (input.args) : [];
|
|
76
|
+
const result = await tool.execute('t', { args }, undefined, undefined, stubCtx());
|
|
77
|
+
return { content: result.content.map((part) => part.text).join('\n') };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
await ctx.session.hook('context', async (event) => {
|
|
83
|
+
const systemParts = event.system.map((part) => (part.type === 'text' ? part.text : '')).filter(Boolean);
|
|
84
|
+
for (const handler of beforeStart) {
|
|
85
|
+
const result = await handler({ systemPrompt: [...systemParts] }, stubCtx());
|
|
86
|
+
if (!result || typeof result !== 'object' || !('systemPrompt' in result))
|
|
87
|
+
continue;
|
|
88
|
+
const parts = typeof result.systemPrompt === 'string'
|
|
89
|
+
? [result.systemPrompt]
|
|
90
|
+
: Array.isArray(result.systemPrompt)
|
|
91
|
+
? result.systemPrompt
|
|
92
|
+
: [];
|
|
93
|
+
for (const part of parts) {
|
|
94
|
+
if (!systemParts.includes(part))
|
|
95
|
+
event.system.push({ type: 'text', text: part });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
await ctx.session.hook('prompt', (event) => {
|
|
100
|
+
for (const handler of inputHandlers)
|
|
101
|
+
void handler({ text: event.prompt.text, source: 'interactive' });
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// OpenCode plugin entry: maps its plugin API onto the shared ExtensionApi the mode extensions consume.
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import cavemanSessionExtension from '../caveman-session/index.js';
|
|
4
|
+
import rtkSessionExtension from '../rtk-session/index.js';
|
|
5
|
+
import comboToggleExtension from '../combo-toggle/index.js';
|
|
6
|
+
import tersioCommandsExtension from '../tersio-commands/index.js';
|
|
7
|
+
import { getSharedComboState } from '../shared/session-state.js';
|
|
8
|
+
import { formatStatus } from '../shared/status.js';
|
|
9
|
+
// Plain object, not Plugin.define: define is an identity wrapper, and a value
|
|
10
|
+
// import would force every install to vendor the whole @opencode dependency tree.
|
|
11
|
+
const plugin = {
|
|
12
|
+
id: 'tersio',
|
|
13
|
+
async setup(ctx) {
|
|
14
|
+
// Entries are scoped per session; an unseen ID restores on its first hook.
|
|
15
|
+
const sessions = new Map();
|
|
16
|
+
const started = new Map();
|
|
17
|
+
let activeSid;
|
|
18
|
+
const loadEntries = async (sid) => {
|
|
19
|
+
const own = (await ctx.storage.get(`entries:${sid}`)) ?? [];
|
|
20
|
+
if (own.length)
|
|
21
|
+
return own;
|
|
22
|
+
// Pre-scoped installs kept one global list; adopt it once, then diverge.
|
|
23
|
+
const legacy = (await ctx.storage.get('entries')) ?? [];
|
|
24
|
+
return [...legacy];
|
|
25
|
+
};
|
|
26
|
+
const sessionEntriesOf = (sid) => {
|
|
27
|
+
let list = sessions.get(sid);
|
|
28
|
+
if (!list) {
|
|
29
|
+
list = [];
|
|
30
|
+
sessions.set(sid, list);
|
|
31
|
+
}
|
|
32
|
+
return list;
|
|
33
|
+
};
|
|
34
|
+
const persist = (sid) => {
|
|
35
|
+
void ctx.storage.set(`entries:${sid}`, sessionEntriesOf(sid)).catch(() => { });
|
|
36
|
+
};
|
|
37
|
+
const stubCtx = (sid) => ({
|
|
38
|
+
hasUI: false,
|
|
39
|
+
cwd: ctx.location.directory,
|
|
40
|
+
sessionManager: sid === undefined
|
|
41
|
+
? { getBranch: () => [], getEntries: () => [] }
|
|
42
|
+
: {
|
|
43
|
+
getBranch: () => sessionEntriesOf(sid),
|
|
44
|
+
getEntries: () => sessionEntriesOf(sid),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
// Starts are chained so appends land in the calling session.
|
|
48
|
+
let startQueue = Promise.resolve();
|
|
49
|
+
const runSessionStart = (sid) => {
|
|
50
|
+
const prev = started.get(sid);
|
|
51
|
+
if (prev)
|
|
52
|
+
return;
|
|
53
|
+
const run = startQueue.then(async () => {
|
|
54
|
+
for (const row of await loadEntries(sid))
|
|
55
|
+
sessionEntriesOf(sid).push(row);
|
|
56
|
+
activeSid = sid;
|
|
57
|
+
try {
|
|
58
|
+
for (const handler of lifecycle.get('session_start') ?? [])
|
|
59
|
+
await handler({}, stubCtx(sid));
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
persist(sid);
|
|
63
|
+
activeSid = undefined;
|
|
64
|
+
}
|
|
65
|
+
}).catch(() => { });
|
|
66
|
+
started.set(sid, run);
|
|
67
|
+
startQueue = run;
|
|
68
|
+
};
|
|
69
|
+
const ensureSession = (sid) => {
|
|
70
|
+
runSessionStart(sid);
|
|
71
|
+
return started.get(sid) ?? Promise.resolve();
|
|
72
|
+
};
|
|
73
|
+
const commands = new Map();
|
|
74
|
+
const tools = new Map();
|
|
75
|
+
const beforeStart = [];
|
|
76
|
+
const inputHandlers = [];
|
|
77
|
+
const lifecycle = new Map();
|
|
78
|
+
const host = {
|
|
79
|
+
hostId: 'opencode',
|
|
80
|
+
setLabel() { },
|
|
81
|
+
registerCommand(name, config) { commands.set(name, config); },
|
|
82
|
+
registerTool(tool) { tools.set(tool.name, tool); },
|
|
83
|
+
appendEntry(type, data) {
|
|
84
|
+
if (activeSid === undefined)
|
|
85
|
+
return;
|
|
86
|
+
sessionEntriesOf(activeSid).push({ type: 'custom', customType: type, data });
|
|
87
|
+
persist(activeSid);
|
|
88
|
+
},
|
|
89
|
+
exec: (cmd, args, opts) => new Promise((resolve) => {
|
|
90
|
+
execFile(cmd, args, { signal: opts?.signal, cwd: opts?.cwd }, (error, stdout, stderr) => {
|
|
91
|
+
const code = error && typeof error.code === 'number' ? error.code : 0;
|
|
92
|
+
resolve({ stdout: stdout ?? '', stderr: stderr ?? '', code, killed: !!error?.killed });
|
|
93
|
+
});
|
|
94
|
+
}),
|
|
95
|
+
cwd: ctx.location.directory,
|
|
96
|
+
on: (event, handler) => {
|
|
97
|
+
if (event === 'before_agent_start') {
|
|
98
|
+
beforeStart.push(handler);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (event === 'input') {
|
|
102
|
+
inputHandlers.push(handler);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const list = lifecycle.get(event) ?? [];
|
|
106
|
+
list.push(handler);
|
|
107
|
+
lifecycle.set(event, list);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
for (const extension of [cavemanSessionExtension, rtkSessionExtension, comboToggleExtension, tersioCommandsExtension])
|
|
111
|
+
extension(host);
|
|
112
|
+
// The first hook of an unseen session runs its session_start handlers.
|
|
113
|
+
const sidOf = (value) => typeof value === 'string' && value ? value : undefined;
|
|
114
|
+
await ctx.command.transform((editor) => {
|
|
115
|
+
for (const [name, config] of commands) {
|
|
116
|
+
editor.add({
|
|
117
|
+
name,
|
|
118
|
+
description: config.description,
|
|
119
|
+
execute: async (input) => {
|
|
120
|
+
const sid = sidOf(input.sessionID);
|
|
121
|
+
if (sid)
|
|
122
|
+
await ensureSession(sid);
|
|
123
|
+
const text = typeof input.prompt?.text === 'string' ? input.prompt.text : '';
|
|
124
|
+
const args = text.includes(' ') ? text.slice(text.indexOf(' ') + 1).trim() : '';
|
|
125
|
+
// The shared handlers report through ui.notify, which has no OpenCode
|
|
126
|
+
// surface. Collect it and send it back so a command never answers blank.
|
|
127
|
+
const notes = [];
|
|
128
|
+
const cmdCtx = {
|
|
129
|
+
...stubCtx(sid),
|
|
130
|
+
hasUI: true,
|
|
131
|
+
ui: { notify: (message) => { notes.push(message); } },
|
|
132
|
+
};
|
|
133
|
+
const prev = activeSid;
|
|
134
|
+
activeSid = sid;
|
|
135
|
+
try {
|
|
136
|
+
await config.handler(args, cmdCtx);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
activeSid = prev;
|
|
140
|
+
}
|
|
141
|
+
const reply = notes.length ? notes.join('\n') : formatStatus(getSharedComboState());
|
|
142
|
+
await ctx.session.prompt({ sessionID: input.sessionID, text: reply });
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
await ctx.tool.transform((editor) => {
|
|
148
|
+
for (const tool of tools.values()) {
|
|
149
|
+
editor.add({
|
|
150
|
+
name: tool.name,
|
|
151
|
+
description: tool.description,
|
|
152
|
+
input: tool.parameters,
|
|
153
|
+
execute: async (input) => {
|
|
154
|
+
const args = Array.isArray(input.args) ? (input.args) : [];
|
|
155
|
+
const result = await tool.execute('t', { args }, undefined, undefined, stubCtx());
|
|
156
|
+
return { content: result.content.map((part) => part.text).join('\n') };
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
await ctx.session.hook('context', async (event) => {
|
|
162
|
+
const sid = sidOf(event.sessionID);
|
|
163
|
+
if (sid)
|
|
164
|
+
await ensureSession(sid);
|
|
165
|
+
const systemParts = event.system.map((part) => (part.type === 'text' ? part.text : '')).filter(Boolean);
|
|
166
|
+
for (const handler of beforeStart) {
|
|
167
|
+
const result = await handler({ systemPrompt: [...systemParts] }, stubCtx(sid));
|
|
168
|
+
if (!result || typeof result !== 'object' || !('systemPrompt' in result))
|
|
169
|
+
continue;
|
|
170
|
+
const parts = typeof result.systemPrompt === 'string'
|
|
171
|
+
? [result.systemPrompt]
|
|
172
|
+
: Array.isArray(result.systemPrompt)
|
|
173
|
+
? result.systemPrompt
|
|
174
|
+
: [];
|
|
175
|
+
for (const part of parts) {
|
|
176
|
+
if (!systemParts.includes(part))
|
|
177
|
+
event.system.push({ type: 'text', text: part });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
await ctx.session.hook('prompt', async (event) => {
|
|
182
|
+
const sid = sidOf(event.sessionID);
|
|
183
|
+
if (sid)
|
|
184
|
+
await ensureSession(sid);
|
|
185
|
+
for (const handler of inputHandlers)
|
|
186
|
+
void handler({ text: event.prompt.text, source: 'interactive' });
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
export default plugin;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// OpenCode plugin entry: maps its plugin API onto the shared ExtensionApi the mode extensions consume.
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import type { Plugin } from '@opencode/plugin';
|
|
4
|
+
import cavemanSessionExtension from '../caveman-session/index.ts';
|
|
5
|
+
import rtkSessionExtension from '../rtk-session/index.ts';
|
|
6
|
+
import comboToggleExtension from '../combo-toggle/index.ts';
|
|
7
|
+
import tersioCommandsExtension from '../tersio-commands/index.ts';
|
|
8
|
+
import { getSharedComboState } from '../shared/session-state.ts';
|
|
9
|
+
import { formatStatus } from '../shared/status.ts';
|
|
10
|
+
import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
|
|
11
|
+
|
|
12
|
+
interface CommandConfig {
|
|
13
|
+
description: string;
|
|
14
|
+
handler: (args: string, ctx: ExtensionCtx) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ToolConfig {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
parameters: unknown;
|
|
21
|
+
execute: (
|
|
22
|
+
toolCallId: string,
|
|
23
|
+
params: { args: string[] },
|
|
24
|
+
signal: AbortSignal | undefined,
|
|
25
|
+
onUpdate: ((data: unknown) => void) | undefined,
|
|
26
|
+
ctx: ExtensionCtx | undefined,
|
|
27
|
+
) => Promise<{ isError: boolean; content: { type: string; text: string }[]; details: Record<string, unknown> }>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Plain object, not Plugin.define: define is an identity wrapper, and a value
|
|
31
|
+
// import would force every install to vendor the whole @opencode dependency tree.
|
|
32
|
+
const plugin: Plugin.Plugin = {
|
|
33
|
+
id: 'tersio',
|
|
34
|
+
async setup(ctx) {
|
|
35
|
+
// Entries are scoped per session; an unseen ID restores on its first hook.
|
|
36
|
+
const sessions = new Map<string, SessionEntry[]>();
|
|
37
|
+
const started = new Map<string, Promise<void>>();
|
|
38
|
+
let activeSid: string | undefined;
|
|
39
|
+
const loadEntries = async (sid: string): Promise<SessionEntry[]> => {
|
|
40
|
+
const own = ((await ctx.storage.get(`entries:${sid}`)) as SessionEntry[] | undefined) ?? [];
|
|
41
|
+
if (own.length) return own;
|
|
42
|
+
// Pre-scoped installs kept one global list; adopt it once, then diverge.
|
|
43
|
+
const legacy = ((await ctx.storage.get('entries')) as SessionEntry[] | undefined) ?? [];
|
|
44
|
+
return [...legacy];
|
|
45
|
+
};
|
|
46
|
+
const sessionEntriesOf = (sid: string): SessionEntry[] => {
|
|
47
|
+
let list = sessions.get(sid);
|
|
48
|
+
if (!list) {
|
|
49
|
+
list = [];
|
|
50
|
+
sessions.set(sid, list);
|
|
51
|
+
}
|
|
52
|
+
return list;
|
|
53
|
+
};
|
|
54
|
+
const persist = (sid: string): void => {
|
|
55
|
+
void ctx.storage.set(`entries:${sid}`, sessionEntriesOf(sid) as unknown as never).catch(() => {});
|
|
56
|
+
};
|
|
57
|
+
const stubCtx = (sid?: string): ExtensionCtx => ({
|
|
58
|
+
hasUI: false,
|
|
59
|
+
cwd: ctx.location.directory,
|
|
60
|
+
sessionManager: sid === undefined
|
|
61
|
+
? { getBranch: () => [], getEntries: () => [] }
|
|
62
|
+
: {
|
|
63
|
+
getBranch: () => sessionEntriesOf(sid),
|
|
64
|
+
getEntries: () => sessionEntriesOf(sid),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
// Starts are chained so appends land in the calling session.
|
|
68
|
+
let startQueue = Promise.resolve();
|
|
69
|
+
const runSessionStart = (sid: string): void => {
|
|
70
|
+
const prev = started.get(sid);
|
|
71
|
+
if (prev) return;
|
|
72
|
+
const run = startQueue.then(async () => {
|
|
73
|
+
for (const row of await loadEntries(sid)) sessionEntriesOf(sid).push(row);
|
|
74
|
+
activeSid = sid;
|
|
75
|
+
try {
|
|
76
|
+
for (const handler of lifecycle.get('session_start') ?? []) await handler({}, stubCtx(sid));
|
|
77
|
+
} finally {
|
|
78
|
+
persist(sid);
|
|
79
|
+
activeSid = undefined;
|
|
80
|
+
}
|
|
81
|
+
}).catch(() => {});
|
|
82
|
+
started.set(sid, run);
|
|
83
|
+
startQueue = run;
|
|
84
|
+
};
|
|
85
|
+
const ensureSession = (sid: string): Promise<void> => {
|
|
86
|
+
runSessionStart(sid);
|
|
87
|
+
return started.get(sid) ?? Promise.resolve();
|
|
88
|
+
};
|
|
89
|
+
const commands = new Map<string, CommandConfig>();
|
|
90
|
+
const tools = new Map<string, ToolConfig>();
|
|
91
|
+
const beforeStart: Array<(event: SystemPromptEvent, ctx: ExtensionCtx) => unknown> = [];
|
|
92
|
+
const inputHandlers: Array<(event: InputEvent) => unknown> = [];
|
|
93
|
+
const lifecycle = new Map<string, Array<(event: unknown, ctx: ExtensionCtx) => unknown>>();
|
|
94
|
+
|
|
95
|
+
const host: ExtensionApi = {
|
|
96
|
+
hostId: 'opencode',
|
|
97
|
+
setLabel() {},
|
|
98
|
+
registerCommand(name, config) { commands.set(name, config); },
|
|
99
|
+
registerTool(tool) { tools.set(tool.name, tool as ToolConfig); },
|
|
100
|
+
appendEntry(type, data) {
|
|
101
|
+
if (activeSid === undefined) return;
|
|
102
|
+
sessionEntriesOf(activeSid).push({ type: 'custom', customType: type, data });
|
|
103
|
+
persist(activeSid);
|
|
104
|
+
},
|
|
105
|
+
exec: (cmd, args, opts) => new Promise((resolve) => {
|
|
106
|
+
execFile(cmd, args, { signal: opts?.signal, cwd: opts?.cwd }, (error, stdout, stderr) => {
|
|
107
|
+
const code = error && typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : 0;
|
|
108
|
+
resolve({ stdout: stdout ?? '', stderr: stderr ?? '', code, killed: !!(error as { killed?: boolean } | null)?.killed });
|
|
109
|
+
});
|
|
110
|
+
}),
|
|
111
|
+
cwd: ctx.location.directory,
|
|
112
|
+
on: (event, handler) => {
|
|
113
|
+
if (event === 'before_agent_start') { beforeStart.push(handler as (event: SystemPromptEvent, ctx: ExtensionCtx) => unknown); return; }
|
|
114
|
+
if (event === 'input') { inputHandlers.push(handler as (event: InputEvent) => unknown); return; }
|
|
115
|
+
const list = lifecycle.get(event) ?? [];
|
|
116
|
+
list.push(handler as (event: unknown, ctx: ExtensionCtx) => unknown);
|
|
117
|
+
lifecycle.set(event, list);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (const extension of [cavemanSessionExtension, rtkSessionExtension, comboToggleExtension, tersioCommandsExtension]) extension(host);
|
|
122
|
+
|
|
123
|
+
// The first hook of an unseen session runs its session_start handlers.
|
|
124
|
+
const sidOf = (value: unknown): string | undefined =>
|
|
125
|
+
typeof value === 'string' && value ? value : undefined;
|
|
126
|
+
|
|
127
|
+
await ctx.command.transform((editor) => {
|
|
128
|
+
for (const [name, config] of commands) {
|
|
129
|
+
editor.add({
|
|
130
|
+
name,
|
|
131
|
+
description: config.description,
|
|
132
|
+
execute: async (input) => {
|
|
133
|
+
const sid = sidOf((input as { sessionID?: unknown }).sessionID);
|
|
134
|
+
if (sid) await ensureSession(sid);
|
|
135
|
+
const text = typeof input.prompt?.text === 'string' ? input.prompt.text : '';
|
|
136
|
+
const args = text.includes(' ') ? text.slice(text.indexOf(' ') + 1).trim() : '';
|
|
137
|
+
// The shared handlers report through ui.notify, which has no OpenCode
|
|
138
|
+
// surface. Collect it and send it back so a command never answers blank.
|
|
139
|
+
const notes: string[] = [];
|
|
140
|
+
const cmdCtx: ExtensionCtx = {
|
|
141
|
+
...stubCtx(sid),
|
|
142
|
+
hasUI: true,
|
|
143
|
+
ui: { notify: (message: string) => { notes.push(message); } },
|
|
144
|
+
};
|
|
145
|
+
const prev = activeSid;
|
|
146
|
+
activeSid = sid;
|
|
147
|
+
try {
|
|
148
|
+
await config.handler(args, cmdCtx);
|
|
149
|
+
} finally {
|
|
150
|
+
activeSid = prev;
|
|
151
|
+
}
|
|
152
|
+
const reply = notes.length ? notes.join('\n') : formatStatus(getSharedComboState());
|
|
153
|
+
await ctx.session.prompt({ sessionID: input.sessionID, text: reply });
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await ctx.tool.transform((editor) => {
|
|
160
|
+
for (const tool of tools.values()) {
|
|
161
|
+
editor.add({
|
|
162
|
+
name: tool.name,
|
|
163
|
+
description: tool.description,
|
|
164
|
+
input: tool.parameters as { type: 'object'; properties: Record<string, unknown>; required?: string[] },
|
|
165
|
+
execute: async (input) => {
|
|
166
|
+
const args = Array.isArray((input as { args?: unknown }).args) ? ((input as { args: string[] }).args) : [];
|
|
167
|
+
const result = await tool.execute('t', { args }, undefined, undefined, stubCtx());
|
|
168
|
+
return { content: result.content.map((part) => part.text).join('\n') };
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await ctx.session.hook('context', async (event) => {
|
|
175
|
+
const sid = sidOf((event as { sessionID?: unknown }).sessionID);
|
|
176
|
+
if (sid) await ensureSession(sid);
|
|
177
|
+
const systemParts = event.system.map((part) => (part.type === 'text' ? part.text : '')).filter(Boolean);
|
|
178
|
+
for (const handler of beforeStart) {
|
|
179
|
+
const result = await handler({ systemPrompt: [...systemParts] } as SystemPromptEvent, stubCtx(sid));
|
|
180
|
+
if (!result || typeof result !== 'object' || !('systemPrompt' in result)) continue;
|
|
181
|
+
const parts = typeof (result as { systemPrompt: unknown }).systemPrompt === 'string'
|
|
182
|
+
? [(result as { systemPrompt: string }).systemPrompt]
|
|
183
|
+
: Array.isArray((result as { systemPrompt: unknown }).systemPrompt)
|
|
184
|
+
? (result as { systemPrompt: string[] }).systemPrompt
|
|
185
|
+
: [];
|
|
186
|
+
for (const part of parts) {
|
|
187
|
+
if (!systemParts.includes(part)) event.system.push({ type: 'text', text: part });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await ctx.session.hook('prompt', async (event) => {
|
|
193
|
+
const sid = sidOf((event as { sessionID?: unknown }).sessionID);
|
|
194
|
+
if (sid) await ensureSession(sid);
|
|
195
|
+
for (const handler of inputHandlers) void handler({ text: event.prompt.text, source: 'interactive' });
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export default plugin;
|