@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.
Files changed (62) hide show
  1. package/README.md +27 -21
  2. package/cli/banner.js +7 -4
  3. package/cli/common.js +14 -13
  4. package/cli/currency.js +1 -2
  5. package/cli/dashboard.js +57 -14
  6. package/cli/doctor-fix.js +88 -21
  7. package/cli/doctor.js +32 -24
  8. package/cli/hosts.js +19 -18
  9. package/cli/install.js +123 -52
  10. package/cli/interactive.js +29 -19
  11. package/cli/manifest.js +7 -0
  12. package/cli/profile.js +42 -11
  13. package/cli/rtk-wiring.js +28 -26
  14. package/cli/settings.js +37 -8
  15. package/cli/uninstall.js +40 -7
  16. package/cli/update.js +22 -14
  17. package/cli/usage.js +39 -14
  18. package/dashboard/dist/index.html +16 -16
  19. package/extensions/ai-addons-updater/index.js +69 -15
  20. package/extensions/ai-addons-updater/index.ts +60 -17
  21. package/extensions/caveman-session/index.js +28 -55
  22. package/extensions/caveman-session/index.ts +28 -55
  23. package/extensions/combo-toggle/index.js +40 -58
  24. package/extensions/combo-toggle/index.ts +38 -67
  25. package/extensions/lib/utils.js +60 -15
  26. package/extensions/lib/utils.ts +59 -15
  27. package/extensions/opencode/index.js +104 -0
  28. package/extensions/opencode/server.js +190 -0
  29. package/extensions/opencode/server.ts +200 -0
  30. package/extensions/rtk-session/index.js +20 -35
  31. package/extensions/rtk-session/index.ts +19 -34
  32. package/extensions/shared/carbon.js +1 -2
  33. package/extensions/shared/carbon.ts +1 -2
  34. package/extensions/shared/host.js +95 -46
  35. package/extensions/shared/host.ts +106 -50
  36. package/extensions/shared/plugin-settings.js +4 -6
  37. package/extensions/shared/plugin-settings.ts +4 -6
  38. package/extensions/shared/pricing.js +79 -25
  39. package/extensions/shared/pricing.ts +80 -26
  40. package/extensions/shared/rtk-gain.js +3 -5
  41. package/extensions/shared/rtk-gain.ts +3 -5
  42. package/extensions/shared/session-state.js +10 -33
  43. package/extensions/shared/session-state.ts +12 -38
  44. package/extensions/shared/status.js +38 -0
  45. package/extensions/shared/status.ts +45 -0
  46. package/extensions/shared/types.js +1 -2
  47. package/extensions/shared/types.ts +2 -2
  48. package/extensions/shared/usage-ledger.js +190 -56
  49. package/extensions/shared/usage-ledger.ts +194 -58
  50. package/extensions/shared/usage-store.js +228 -64
  51. package/extensions/shared/usage-store.ts +235 -67
  52. package/extensions/tersio-commands/index.js +4 -9
  53. package/extensions/tersio-commands/index.ts +4 -9
  54. package/package.json +8 -2
  55. package/tersio.js +3 -3
  56. package/cli/agent-hosts.js +0 -42
  57. package/cli/agents.js +0 -266
  58. package/cli/layer.js +0 -22
  59. package/cli/omp-layer.js +0 -51
  60. package/cli/pi-layer.js +0 -37
  61. package/cli/pi-wiring.js +0 -92
  62. 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
 
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,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
- // 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
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(os.homedir(), '.tersio');
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(os.homedir(), '.omp', 'plugins', legacy);
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;