@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.
Files changed (63) hide show
  1. package/README.md +28 -24
  2. package/cli/banner.js +7 -5
  3. package/cli/common.js +14 -13
  4. package/cli/currency.js +2 -7
  5. package/cli/dashboard.js +220 -13
  6. package/cli/doctor-fix.js +99 -29
  7. package/cli/doctor.js +32 -35
  8. package/cli/hosts.js +23 -26
  9. package/cli/install.js +228 -115
  10. package/cli/interactive.js +65 -21
  11. package/cli/manifest.js +8 -4
  12. package/cli/profile.js +49 -16
  13. package/cli/reset.js +3 -7
  14. package/cli/rtk-wiring.js +29 -32
  15. package/cli/settings.js +40 -16
  16. package/cli/uninstall.js +79 -29
  17. package/cli/update.js +27 -20
  18. package/cli/usage.js +54 -19
  19. package/dashboard/dist/index.html +18 -18
  20. package/extensions/ai-addons-updater/index.js +75 -20
  21. package/extensions/ai-addons-updater/index.ts +67 -23
  22. package/extensions/caveman-session/index.js +33 -75
  23. package/extensions/caveman-session/index.ts +35 -77
  24. package/extensions/combo-toggle/index.js +40 -62
  25. package/extensions/combo-toggle/index.ts +38 -72
  26. package/extensions/lib/utils.js +64 -15
  27. package/extensions/lib/utils.ts +62 -15
  28. package/extensions/opencode/index.js +104 -0
  29. package/extensions/opencode/server.js +190 -0
  30. package/extensions/opencode/server.ts +200 -0
  31. package/extensions/rtk-session/index.js +38 -57
  32. package/extensions/rtk-session/index.ts +36 -55
  33. package/extensions/shared/carbon.js +4 -10
  34. package/extensions/shared/carbon.ts +5 -10
  35. package/extensions/shared/host.js +95 -56
  36. package/extensions/shared/host.ts +106 -60
  37. package/extensions/shared/plugin-settings.js +6 -18
  38. package/extensions/shared/plugin-settings.ts +6 -18
  39. package/extensions/shared/pricing.js +79 -27
  40. package/extensions/shared/pricing.ts +80 -28
  41. package/extensions/shared/rtk-gain.js +4 -11
  42. package/extensions/shared/rtk-gain.ts +4 -11
  43. package/extensions/shared/session-state.js +28 -39
  44. package/extensions/shared/session-state.ts +29 -38
  45. package/extensions/shared/status.js +38 -0
  46. package/extensions/shared/status.ts +45 -0
  47. package/extensions/shared/types.js +1 -2
  48. package/extensions/shared/types.ts +2 -2
  49. package/extensions/shared/usage-ledger.js +312 -57
  50. package/extensions/shared/usage-ledger.ts +321 -61
  51. package/extensions/shared/usage-store.js +354 -38
  52. package/extensions/shared/usage-store.ts +360 -43
  53. package/extensions/tersio-commands/index.js +5 -14
  54. package/extensions/tersio-commands/index.ts +5 -14
  55. package/package.json +12 -7
  56. package/tersio.js +4 -6
  57. package/cli/agent-hosts.js +0 -42
  58. package/cli/agents.js +0 -266
  59. package/cli/layer.js +0 -24
  60. package/cli/omp-layer.js +0 -51
  61. package/cli/pi-layer.js +0 -37
  62. package/cli/pi-wiring.js +0 -92
  63. package/extensions/shared/omp-prompt.js +0 -12
@@ -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
- export function resolveRtkBinary(managedDir = path.join(os.homedir(), '.bun', 'bin')) {
26
- const name = process.platform === 'win32' ? 'rtk.exe' : 'rtk';
27
- const candidates = (process.env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, name));
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(os.homedir(), '.pi', 'agent');
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
- // Tersio-owned data home: ~/.tersio. First use moves legacy
198
- // ~/.omp/plugins/tersio-* files over so existing installs keep their history.
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
- return path.join(os.homedir(), '.tersio');
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(os.homedir(), '.omp', 'plugins', legacy);
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);
@@ -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
- export function resolveRtkBinary(managedDir = path.join(os.homedir(), '.bun', 'bin')): string | null {
51
- const name = process.platform === 'win32' ? 'rtk.exe' : 'rtk';
52
- const candidates = (process.env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, name));
53
- if (managedDir) candidates.push(path.join(managedDir, name));
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(os.homedir(), '.pi', 'agent');
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
- // Tersio-owned data home: ~/.tersio. First use moves legacy
204
- // ~/.omp/plugins/tersio-* files over so existing installs keep their history.
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
- return path.join(os.homedir(), '.tersio');
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(os.homedir(), '.omp', 'plugins', legacy);
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;