@krtclcdy/tersio 2.24.0 → 2.25.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 +28 -24
- package/cli/banner.js +7 -5
- package/cli/common.js +14 -13
- package/cli/currency.js +2 -7
- package/cli/dashboard.js +220 -13
- package/cli/doctor-fix.js +99 -29
- package/cli/doctor.js +32 -35
- package/cli/hosts.js +23 -26
- package/cli/install.js +228 -115
- package/cli/interactive.js +65 -21
- package/cli/manifest.js +8 -4
- package/cli/profile.js +49 -16
- package/cli/reset.js +3 -7
- package/cli/rtk-wiring.js +29 -32
- package/cli/settings.js +40 -16
- package/cli/uninstall.js +79 -29
- package/cli/update.js +27 -20
- package/cli/usage.js +54 -19
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +75 -20
- package/extensions/ai-addons-updater/index.ts +67 -23
- package/extensions/caveman-session/index.js +33 -75
- package/extensions/caveman-session/index.ts +35 -77
- package/extensions/combo-toggle/index.js +40 -62
- package/extensions/combo-toggle/index.ts +38 -72
- package/extensions/lib/utils.js +64 -15
- package/extensions/lib/utils.ts +62 -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 +38 -57
- package/extensions/rtk-session/index.ts +36 -55
- package/extensions/shared/carbon.js +4 -10
- package/extensions/shared/carbon.ts +5 -10
- package/extensions/shared/host.js +95 -56
- package/extensions/shared/host.ts +106 -60
- package/extensions/shared/plugin-settings.js +6 -18
- package/extensions/shared/plugin-settings.ts +6 -18
- package/extensions/shared/pricing.js +79 -27
- package/extensions/shared/pricing.ts +80 -28
- package/extensions/shared/rtk-gain.js +4 -11
- package/extensions/shared/rtk-gain.ts +4 -11
- package/extensions/shared/session-state.js +28 -39
- package/extensions/shared/session-state.ts +29 -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 +312 -57
- package/extensions/shared/usage-ledger.ts +321 -61
- package/extensions/shared/usage-store.js +354 -38
- package/extensions/shared/usage-store.ts +360 -43
- package/extensions/tersio-commands/index.js +5 -14
- package/extensions/tersio-commands/index.ts +5 -14
- package/package.json +12 -7
- package/tersio.js +4 -6
- package/cli/agent-hosts.js +0 -42
- package/cli/agents.js +0 -266
- package/cli/layer.js +0 -24
- 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.js
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
|
import { createHash } from 'node:crypto';
|
|
4
3
|
import { accessSync, constants, createWriteStream, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
5
4
|
import fs from 'node:fs/promises';
|
|
@@ -13,6 +12,11 @@ export function withResolvers() {
|
|
|
13
12
|
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
14
13
|
return { promise, resolve, reject };
|
|
15
14
|
}
|
|
15
|
+
// os.homedir() can ignore a runtime HOME, so a redirected home would read the real one.
|
|
16
|
+
export function homeDir() {
|
|
17
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
18
|
+
return home && home.trim() !== '' ? home : os.homedir();
|
|
19
|
+
}
|
|
16
20
|
export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
|
|
17
21
|
export const CAVEMAN_REMOTE_RULE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/caveman/SKILL.md';
|
|
18
22
|
const RTK_PLATFORM_SPECS = {
|
|
@@ -22,11 +26,13 @@ const RTK_PLATFORM_SPECS = {
|
|
|
22
26
|
'darwin/x64': { triple: 'x86_64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
|
|
23
27
|
'darwin/arm64': { triple: 'aarch64-apple-darwin', ext: '.tar.gz', binary: 'rtk' },
|
|
24
28
|
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
29
|
+
// First executable `name` on PATH, else the managed Bun bin dir.
|
|
30
|
+
function firstExecutable(name, managedDir) {
|
|
31
|
+
const ext = process.platform === 'win32' ? '.cmd' : '';
|
|
32
|
+
const candidates = (process.env.PATH || '').split(path.delimiter).filter(Boolean)
|
|
33
|
+
.flatMap((dir) => [path.join(dir, name + ext), path.join(dir, name)]);
|
|
28
34
|
if (managedDir)
|
|
29
|
-
candidates.push(path.join(managedDir, name));
|
|
35
|
+
candidates.push(path.join(managedDir, name + ext), path.join(managedDir, name));
|
|
30
36
|
for (const candidate of candidates) {
|
|
31
37
|
try {
|
|
32
38
|
if (!statSync(candidate).isFile())
|
|
@@ -38,6 +44,13 @@ export function resolveRtkBinary(managedDir = path.join(os.homedir(), '.bun', 'b
|
|
|
38
44
|
}
|
|
39
45
|
return null;
|
|
40
46
|
}
|
|
47
|
+
export function resolveRtkBinary(managedDir = path.join(homeDir(), '.bun', 'bin')) {
|
|
48
|
+
return firstExecutable(process.platform === 'win32' ? 'rtk.exe' : 'rtk', managedDir);
|
|
49
|
+
}
|
|
50
|
+
// A host binary to scan for its subagent marker.
|
|
51
|
+
export function resolveHostBinary(name, managedDir = path.join(homeDir(), '.bun', 'bin')) {
|
|
52
|
+
return firstExecutable(name, managedDir);
|
|
53
|
+
}
|
|
41
54
|
export function rtkPlatformSpec(platform = process.platform, arch = process.arch) {
|
|
42
55
|
return RTK_PLATFORM_SPECS[`${platform}/${arch}`] || null;
|
|
43
56
|
}
|
|
@@ -48,23 +61,25 @@ function redirectNext(res, url) {
|
|
|
48
61
|
}
|
|
49
62
|
return null;
|
|
50
63
|
}
|
|
51
|
-
// pi sets PI_CODING_AGENT; OMP does not. OMP does read PI_CODING_AGENT_DIR as
|
|
52
|
-
// its own relocation variable, so the override only counts under that marker.
|
|
64
|
+
// 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.
|
|
53
65
|
export function isPiProcess() {
|
|
54
66
|
return process.env.PI_CODING_AGENT === 'true';
|
|
55
67
|
}
|
|
56
68
|
export function piAgentDir() {
|
|
57
69
|
const override = process.env.PI_CODING_AGENT_DIR;
|
|
58
|
-
return isPiProcess() && override ? override : path.join(
|
|
70
|
+
return isPiProcess() && override ? override : path.join(homeDir(), '.pi', 'agent');
|
|
59
71
|
}
|
|
60
|
-
// Ponytail is a Tersio dependency that npm hoists, so it can sit in Tersio's
|
|
61
|
-
// node_modules, a host's plugin dir, or beside it. Walking up covers all.
|
|
72
|
+
// 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.
|
|
62
73
|
export function findHoistedPackage(pkg, fromDir, ...relInside) {
|
|
63
74
|
let dir = fromDir;
|
|
64
75
|
for (;;) {
|
|
65
76
|
const candidate = path.join(dir, 'node_modules', ...pkg.split('/'), ...relInside);
|
|
66
77
|
if (existsSync(candidate))
|
|
67
78
|
return candidate;
|
|
79
|
+
// pi installs npm packages under ~/.pi/agent/npm/node_modules.
|
|
80
|
+
const piCandidate = path.join(dir, 'npm', 'node_modules', ...pkg.split('/'), ...relInside);
|
|
81
|
+
if (existsSync(piCandidate))
|
|
82
|
+
return piCandidate;
|
|
68
83
|
const parent = path.dirname(dir);
|
|
69
84
|
if (parent === dir)
|
|
70
85
|
return null;
|
|
@@ -194,11 +209,45 @@ export async function readTextIfExists(p) {
|
|
|
194
209
|
return null;
|
|
195
210
|
}
|
|
196
211
|
}
|
|
197
|
-
//
|
|
198
|
-
|
|
212
|
+
// 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.
|
|
213
|
+
export async function fileContains(filePath, needleText) {
|
|
214
|
+
const needle = Buffer.from(needleText, 'utf8');
|
|
215
|
+
if (needle.length === 0)
|
|
216
|
+
return false;
|
|
217
|
+
const keep = needle.length - 1;
|
|
218
|
+
let handle;
|
|
219
|
+
try {
|
|
220
|
+
handle = await fs.open(filePath, 'r');
|
|
221
|
+
const { size } = await handle.stat();
|
|
222
|
+
const chunk = Buffer.allocUnsafe(1 << 20);
|
|
223
|
+
let tail = Buffer.alloc(0);
|
|
224
|
+
for (let position = 0; position < size;) {
|
|
225
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
|
|
226
|
+
if (bytesRead === 0)
|
|
227
|
+
break;
|
|
228
|
+
const window = Buffer.concat([tail, chunk.subarray(0, bytesRead)]);
|
|
229
|
+
if (window.includes(needle))
|
|
230
|
+
return true;
|
|
231
|
+
tail = window.subarray(Math.max(0, window.length - keep));
|
|
232
|
+
position += bytesRead;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
await handle?.close().catch(() => { });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Tersio-owned data home: ~/.tersio. First use moves legacy ~/.omp/plugins/tersio-* files over so existing installs keep their history.
|
|
199
244
|
const migratedTersioFiles = new Set();
|
|
200
245
|
export function tersioHome() {
|
|
201
|
-
|
|
246
|
+
// Matches the other TERSIO_* overrides so a test never reads the real home.
|
|
247
|
+
const override = process.env.TERSIO_HOME;
|
|
248
|
+
if (override && override.trim() !== '')
|
|
249
|
+
return override;
|
|
250
|
+
return path.join(homeDir(), '.tersio');
|
|
202
251
|
}
|
|
203
252
|
export function tersioDataPath(name, legacy) {
|
|
204
253
|
const dest = path.join(tersioHome(), name);
|
|
@@ -206,7 +255,7 @@ export function tersioDataPath(name, legacy) {
|
|
|
206
255
|
migratedTersioFiles.add(name);
|
|
207
256
|
try {
|
|
208
257
|
if (!existsSync(dest)) {
|
|
209
|
-
const src = path.join(
|
|
258
|
+
const src = path.join(homeDir(), '.omp', 'plugins', legacy);
|
|
210
259
|
if (existsSync(src)) {
|
|
211
260
|
mkdirSync(tersioHome(), { recursive: true });
|
|
212
261
|
renameSync(src, dest);
|
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,12 +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.
|
|
253
|
+
const override = process.env.TERSIO_HOME;
|
|
254
|
+
if (override && override.trim() !== '') return override;
|
|
255
|
+
return path.join(homeDir(), '.tersio');
|
|
209
256
|
}
|
|
210
257
|
|
|
211
258
|
|
|
@@ -215,7 +262,7 @@ export function tersioDataPath(name: string, legacy: string): string {
|
|
|
215
262
|
migratedTersioFiles.add(name);
|
|
216
263
|
try {
|
|
217
264
|
if (!existsSync(dest)) {
|
|
218
|
-
const src = path.join(
|
|
265
|
+
const src = path.join(homeDir(), '.omp', 'plugins', legacy);
|
|
219
266
|
if (existsSync(src)) {
|
|
220
267
|
mkdirSync(tersioHome(), { recursive: true });
|
|
221
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;
|