@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
@@ -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;
@@ -1,6 +1,8 @@
1
- import { activeModesSummary, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
1
+ import { getSharedComboState, hasModeState, isOmpSubagentPrompt, lastCustomValue, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode, systemPromptIncludes } from '../shared/session-state.js';
2
2
  import { injectPromptText, onHostEvent, setExtensionLabel, stringArrayToolParams } from '../shared/host.js';
3
3
  import { readRtkDefault } from '../shared/plugin-settings.js';
4
+ import { resolveRtkBinary } from '../lib/utils.js';
5
+ import { announceStatus } from '../shared/status.js';
4
6
  const DEFAULT_ENABLED = false;
5
7
  function asBoolean(value) {
6
8
  if (typeof value === 'boolean')
@@ -23,37 +25,23 @@ function setRtkProcessEnabled(enabled) {
23
25
  const RTK_PROMPT = `RTK guidance active. RTK automatically rewrites eligible Bash calls through the installed rtk hook. Prefer rtk for noisy shell output, but use exact raw output for state changes, checksums, patches, and diagnostics that need full bytes.`;
24
26
  export default function rtkSessionExtension(pi) {
25
27
  let enabled = DEFAULT_ENABLED;
26
- let isActive = false;
27
- let lastCtx = undefined;
28
- function syncStatus(ctx) {
29
- lastCtx = paintableCtx(lastCtx, ctx);
30
- const c = lastCtx;
31
- if (!c?.ui?.setStatus)
32
- return;
33
- // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
34
- if (isComboPresetActive() || !enabled) {
35
- c.ui.setStatus('rtk', undefined);
36
- return;
37
- }
38
- paintStatusBar(c.ui, 'rtk', '⚡', 'rtk: ON', isActive);
28
+ function fail(text) {
29
+ return { isError: true, content: [{ type: 'text', text }], details: { enabled } };
39
30
  }
40
31
  function setEnabled(next, ctx) {
41
32
  enabled = Boolean(next);
42
33
  pi.appendEntry?.('rtk-mode', { enabled });
43
34
  setSharedComboMode('rtk', enabled);
44
35
  setRtkProcessEnabled(enabled);
45
- const active = activeModesSummary(getSharedComboState());
46
- ctx?.ui?.notify?.(enabled ? `RTK on — compact shell output for this session. Active: ${active}.` : `RTK off. Active: ${active}.`, 'info');
36
+ announceStatus(ctx);
47
37
  }
48
38
  setExtensionLabel(pi, 'RTK session toggle');
49
- // Live mirror: a /tersio or /combo switch publishes shared state — adopt
50
- // it at once so the next turn and the rtk_run gate see it, no reload.
39
+ // Live mirror: a /tersio or /combo switch publishes shared state — adopt it at once so the next turn and the rtk_run gate see it, no reload.
51
40
  function syncFromShared(state) {
52
41
  enabled = state.rtk === 'on';
53
42
  setRtkProcessEnabled(enabled);
54
- syncStatus();
55
43
  }
56
- setSharedComboListener(syncFromShared);
44
+ setSharedComboListener('rtk', syncFromShared);
57
45
  pi.registerCommand?.('rtk', {
58
46
  description: 'Toggle RTK compact shell-output guidance for this session',
59
47
  handler: async (args, ctx) => {
@@ -82,49 +70,41 @@ export default function rtkSessionExtension(pi) {
82
70
  description: "Arguments passed to rtk, e.g. ['git','status'] or ['read','src/index.ts']",
83
71
  }),
84
72
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
85
- if (!enabled) {
73
+ // A subagent's own flag sits at its start-up default, so honour shared state too.
74
+ if (!(enabled || getSharedComboState().rtk === 'on'))
75
+ return fail('RTK mode is off. Run /rtk on for this session, or use bash explicitly.');
76
+ if (typeof pi.exec !== 'function')
77
+ return fail('This host cannot run rtk.');
78
+ onUpdate?.({ content: [{ type: 'text', text: `rtk ${params.args.join(' ')}` }], details: { phase: 'start' } });
79
+ try {
80
+ // A GUI-launched host may lack ~/.bun/bin on PATH, so prefer the resolved path.
81
+ const result = await pi.exec(resolveRtkBinary() ?? 'rtk', params.args, { signal, cwd: ctx?.cwd || pi.cwd });
82
+ const text = [result.stdout, result.stderr].filter(Boolean).join('\n');
86
83
  return {
87
- isError: true,
88
- content: [{ type: 'text', text: 'RTK mode is off. Run /rtk on for this session, or use bash explicitly.' }],
89
- details: { enabled },
84
+ isError: result.code !== 0,
85
+ content: [{ type: 'text', text: text || `rtk exited ${result.code}` }],
86
+ details: { code: result.code, killed: result.killed, enabled },
90
87
  };
91
88
  }
92
- onUpdate?.({ content: [{ type: 'text', text: `rtk ${params.args.join(' ')}` }], details: { phase: 'start' } });
93
- const result = await pi.exec('rtk', params.args, { signal, cwd: ctx?.cwd || pi.cwd });
94
- const text = [result.stdout, result.stderr].filter(Boolean).join('\n');
95
- return {
96
- isError: result.code !== 0,
97
- content: [{ type: 'text', text: text || `rtk exited ${result.code}` }],
98
- details: { code: result.code, killed: result.killed, enabled },
99
- };
89
+ catch (e) {
90
+ return fail(`rtk could not run: ${e instanceof Error ? e.message : String(e)}`);
91
+ }
100
92
  },
101
93
  });
102
- onHostEvent(pi, 'input', async (event) => {
103
- if (event?.source === 'extension')
104
- return;
105
- const t = normalizeInputCommand(event?.text);
106
- if (t === 'rtk on' || t === 'use rtk')
107
- setEnabled(true);
108
- if (t === 'rtk off' || t === 'stop rtk')
109
- setEnabled(false);
110
- });
111
94
  function restoreEnabled(ctx) {
112
95
  const entries = sessionEntries(ctx);
113
- // Publish the persisted combo state (incl. combo-level) before painting:
114
- // bar suppression reads the in-process bridge, which is empty in a fresh
115
- // host until the combo extension reconciles — and its reconcile is
116
- // UI-gated. Deriving it here makes suppression independent of load order.
117
- reconcileSharedComboEntries(entries);
118
- // Persisted session state wins; a fresh session falls back to the
119
- // installer/user-configured default (off unless configured).
96
+ // A subagent's branch has no Tersio entries; reconciling it would wipe shared state.
97
+ if (hasModeState(entries))
98
+ reconcileSharedComboEntries(entries);
99
+ // Persisted session state wins; a fresh session falls back to the installer/user-configured default (off unless configured).
120
100
  const persisted = resolveEnabled(entries);
121
- enabled = typeof persisted === 'boolean' ? persisted : readRtkDefault();
101
+ enabled = typeof persisted === 'boolean' ? persisted : enabled || readRtkDefault();
122
102
  setRtkProcessEnabled(enabled);
123
- syncStatus(ctx);
103
+ announceStatus(ctx);
124
104
  }
105
+ // Start, resume, and branch all land here, so every session type shows it.
125
106
  pi.on('session_start', async (_event, ctx) => {
126
107
  restoreEnabled(ctx);
127
- ctx?.ui?.notify?.(`RTK loaded: ${enabled ? 'on' : 'off'}`, 'info');
128
108
  });
129
109
  onHostEvent(pi, 'session_branch', async (_event, ctx) => {
130
110
  restoreEnabled(ctx);
@@ -132,18 +112,19 @@ export default function rtkSessionExtension(pi) {
132
112
  pi.on('session_tree', async (_event, ctx) => {
133
113
  restoreEnabled(ctx);
134
114
  });
135
- pi.on('agent_start', async (_event, ctx) => {
136
- isActive = true;
137
- syncStatus(ctx);
115
+ onHostEvent(pi, 'session_switch', async (_event, ctx) => {
116
+ restoreEnabled(ctx);
138
117
  });
139
- pi.on('agent_end', async (_event, ctx) => {
140
- isActive = false;
141
- syncStatus(ctx);
118
+ onHostEvent(pi, 'session_compact', async (_event, ctx) => {
119
+ restoreEnabled(ctx);
142
120
  });
143
121
  pi.on('before_agent_start', async (event) => {
144
122
  const active = isOmpSubagentPrompt(event.systemPrompt) ? getSharedComboState().rtk === 'on' : enabled;
145
123
  if (!active)
146
124
  return;
125
+ // Skip when already present, so a host that re-presents the prompt cannot stack it.
126
+ if (systemPromptIncludes(event.systemPrompt, RTK_PROMPT))
127
+ return;
147
128
  return injectPromptText(pi, event, RTK_PROMPT);
148
129
  });
149
130
  }
@@ -1,7 +1,9 @@
1
- import { activeModesSummary, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.ts';
1
+ import { getSharedComboState, hasModeState, isOmpSubagentPrompt, lastCustomValue, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode, systemPromptIncludes } from '../shared/session-state.ts';
2
2
  import { injectPromptText, onHostEvent, setExtensionLabel, stringArrayToolParams } from '../shared/host.ts';
3
3
  import { readRtkDefault } from '../shared/plugin-settings.ts';
4
- import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
4
+ import { resolveRtkBinary } from '../lib/utils.ts';
5
+ import { announceStatus } from '../shared/status.ts';
6
+ import type { ExtensionApi, ExtensionCtx, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
5
7
 
6
8
  const DEFAULT_ENABLED = false;
7
9
 
@@ -25,40 +27,28 @@ const RTK_PROMPT = `RTK guidance active. RTK automatically rewrites eligible Bas
25
27
 
26
28
  export default function rtkSessionExtension(pi: ExtensionApi): void {
27
29
  let enabled = DEFAULT_ENABLED;
28
- let isActive = false;
29
- let lastCtx: ExtensionCtx | undefined = undefined;
30
-
31
- function syncStatus(ctx?: ExtensionCtx): void {
32
- lastCtx = paintableCtx(lastCtx, ctx);
33
- const c = lastCtx;
34
- if (!c?.ui?.setStatus) return;
35
- // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
36
- if (isComboPresetActive() || !enabled) {
37
- c.ui.setStatus('rtk', undefined);
38
- return;
39
- }
40
- paintStatusBar(c.ui, 'rtk', '⚡', 'rtk: ON', isActive);
30
+
31
+ function fail(text: string) {
32
+ return { isError: true as const, content: [{ type: 'text' as const, text }], details: { enabled } };
41
33
  }
42
34
 
35
+
43
36
  function setEnabled(next: unknown, ctx?: ExtensionCtx): void {
44
37
  enabled = Boolean(next);
45
38
  pi.appendEntry?.('rtk-mode', { enabled });
46
39
  setSharedComboMode('rtk', enabled);
47
40
  setRtkProcessEnabled(enabled);
48
- const active = activeModesSummary(getSharedComboState());
49
- ctx?.ui?.notify?.(enabled ? `RTK on — compact shell output for this session. Active: ${active}.` : `RTK off. Active: ${active}.`, 'info');
41
+ announceStatus(ctx);
50
42
  }
51
43
 
52
44
  setExtensionLabel(pi, 'RTK session toggle');
53
45
 
54
- // Live mirror: a /tersio or /combo switch publishes shared state — adopt
55
- // it at once so the next turn and the rtk_run gate see it, no reload.
46
+ // Live mirror: a /tersio or /combo switch publishes shared state — adopt it at once so the next turn and the rtk_run gate see it, no reload.
56
47
  function syncFromShared(state: { rtk: string }): void {
57
48
  enabled = state.rtk === 'on';
58
49
  setRtkProcessEnabled(enabled);
59
- syncStatus();
60
50
  }
61
- setSharedComboListener(syncFromShared);
51
+ setSharedComboListener('rtk', syncFromShared);
62
52
  pi.registerCommand?.('rtk', {
63
53
  description: 'Toggle RTK compact shell-output guidance for this session',
64
54
  handler: async (args, ctx) => {
@@ -88,48 +78,39 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
88
78
  description: "Arguments passed to rtk, e.g. ['git','status'] or ['read','src/index.ts']",
89
79
  }),
90
80
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
91
- if (!enabled) {
81
+ // A subagent's own flag sits at its start-up default, so honour shared state too.
82
+ if (!(enabled || getSharedComboState().rtk === 'on')) return fail('RTK mode is off. Run /rtk on for this session, or use bash explicitly.');
83
+ if (typeof pi.exec !== 'function') return fail('This host cannot run rtk.');
84
+ onUpdate?.({ content: [{ type: 'text', text: `rtk ${params.args.join(' ')}` }], details: { phase: 'start' } });
85
+ try {
86
+ // A GUI-launched host may lack ~/.bun/bin on PATH, so prefer the resolved path.
87
+ const result = await pi.exec(resolveRtkBinary() ?? 'rtk', params.args, { signal, cwd: ctx?.cwd || pi.cwd });
88
+ const text = [result.stdout, result.stderr].filter(Boolean).join('\n');
92
89
  return {
93
- isError: true,
94
- content: [{ type: 'text', text: 'RTK mode is off. Run /rtk on for this session, or use bash explicitly.' }],
95
- details: { enabled },
90
+ isError: result.code !== 0,
91
+ content: [{ type: 'text', text: text || `rtk exited ${result.code}` }],
92
+ details: { code: result.code, killed: result.killed, enabled },
96
93
  };
94
+ } catch (e) {
95
+ return fail(`rtk could not run: ${e instanceof Error ? e.message : String(e)}`);
97
96
  }
98
- onUpdate?.({ content: [{ type: 'text', text: `rtk ${params.args.join(' ')}` }], details: { phase: 'start' } });
99
- const result = await pi.exec!('rtk', params.args, { signal, cwd: ctx?.cwd || pi.cwd });
100
- const text = [result.stdout, result.stderr].filter(Boolean).join('\n');
101
- return {
102
- isError: result.code !== 0,
103
- content: [{ type: 'text', text: text || `rtk exited ${result.code}` }],
104
- details: { code: result.code, killed: result.killed, enabled },
105
- };
106
97
  },
107
98
  });
108
99
 
109
- onHostEvent<InputEvent>(pi, 'input', async (event) => {
110
- if (event?.source === 'extension') return;
111
- const t = normalizeInputCommand(event?.text);
112
- if (t === 'rtk on' || t === 'use rtk') setEnabled(true);
113
- if (t === 'rtk off' || t === 'stop rtk') setEnabled(false);
114
- });
115
100
  function restoreEnabled(ctx?: ExtensionCtx): void {
116
101
  const entries = sessionEntries(ctx);
117
- // Publish the persisted combo state (incl. combo-level) before painting:
118
- // bar suppression reads the in-process bridge, which is empty in a fresh
119
- // host until the combo extension reconciles — and its reconcile is
120
- // UI-gated. Deriving it here makes suppression independent of load order.
121
- reconcileSharedComboEntries(entries);
122
- // Persisted session state wins; a fresh session falls back to the
123
- // installer/user-configured default (off unless configured).
102
+ // A subagent's branch has no Tersio entries; reconciling it would wipe shared state.
103
+ if (hasModeState(entries)) reconcileSharedComboEntries(entries);
104
+ // Persisted session state wins; a fresh session falls back to the installer/user-configured default (off unless configured).
124
105
  const persisted = resolveEnabled(entries);
125
- enabled = typeof persisted === 'boolean' ? persisted : readRtkDefault();
106
+ enabled = typeof persisted === 'boolean' ? persisted : enabled || readRtkDefault();
126
107
  setRtkProcessEnabled(enabled);
127
- syncStatus(ctx);
108
+ announceStatus(ctx);
128
109
  }
129
110
 
111
+ // Start, resume, and branch all land here, so every session type shows it.
130
112
  pi.on('session_start', async (_event, ctx) => {
131
113
  restoreEnabled(ctx);
132
- ctx?.ui?.notify?.(`RTK loaded: ${enabled ? 'on' : 'off'}`, 'info');
133
114
  });
134
115
 
135
116
  onHostEvent(pi, 'session_branch', async (_event, ctx) => {
@@ -140,19 +121,19 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
140
121
  restoreEnabled(ctx);
141
122
  });
142
123
 
143
- pi.on('agent_start', async (_event, ctx) => {
144
- isActive = true;
145
- syncStatus(ctx);
124
+ onHostEvent(pi, 'session_switch', async (_event, ctx) => {
125
+ restoreEnabled(ctx);
146
126
  });
147
127
 
148
- pi.on('agent_end', async (_event, ctx) => {
149
- isActive = false;
150
- syncStatus(ctx);
128
+ onHostEvent(pi, 'session_compact', async (_event, ctx) => {
129
+ restoreEnabled(ctx);
151
130
  });
152
131
 
153
132
  pi.on<SystemPromptEvent>('before_agent_start', async (event) => {
154
133
  const active = isOmpSubagentPrompt(event.systemPrompt) ? getSharedComboState().rtk === 'on' : enabled;
155
134
  if (!active) return;
135
+ // Skip when already present, so a host that re-presents the prompt cannot stack it.
136
+ if (systemPromptIncludes(event.systemPrompt, RTK_PROMPT)) return;
156
137
  return injectPromptText(pi, event, RTK_PROMPT);
157
138
  });
158
139
  }
@@ -1,11 +1,4 @@
1
- // extensions/shared/carbon.ts — CO2/energy footprints, EcoLogits-style.
2
- //
3
- // Port of the EcoLogits v0.8.2 LLM impact model (genai-impact/ecologits,
4
- // MPL-2.0 — see ECO_NOTICE.md): the single-stream (batch-1) ceiling divided by
5
- // serving_concurrency for continuous-batching amortization. Model params come
6
- // from EcoLogits' registry, else a documented class borrow; unknown ids fall
7
- // to the gpt-4o default. Render everything from here as ~est., never merged
8
- // with measured figures.
1
+ // EcoLogits v0.8.2 port; always render as ~est, never merged with measured.
9
2
  // EcoLogits 0.8.2 constants — kWh, kgCO2eq, seconds, GB.
10
3
  const Q_BITS = 4;
11
4
  const E_ALPHA = 8.91e-8;
@@ -28,8 +21,9 @@ const GRIDS = {
28
21
  };
29
22
  // Serving amortization: single-stream ceiling ÷ concurrency ≈ served impact.
30
23
  export const SERVING_CONCURRENCY = 32;
31
- // Registry values verbatim from EcoLogits' model_repository; borrows cite
32
- // the public param disclosure they stand in for (MoE active/total).
24
+ /** EcoLogits release this port tracks, so a UI can name it instead of guessing. */
25
+ export const ECOLOGITS_VERSION = '0.8.2';
26
+ // Registry values verbatim from EcoLogits' model_repository; borrows cite the public param disclosure they stand in for (MoE active/total).
33
27
  const TABLE = [
34
28
  { match: 'opus', active: 625.0, total: 2000, provider: 'anthropic', source: 'registry' },
35
29
  { match: 'sonnet', active: 137.5, total: 440, provider: 'anthropic', source: 'registry' },
@@ -1,11 +1,4 @@
1
- // extensions/shared/carbon.ts — CO2/energy footprints, EcoLogits-style.
2
- //
3
- // Port of the EcoLogits v0.8.2 LLM impact model (genai-impact/ecologits,
4
- // MPL-2.0 — see ECO_NOTICE.md): the single-stream (batch-1) ceiling divided by
5
- // serving_concurrency for continuous-batching amortization. Model params come
6
- // from EcoLogits' registry, else a documented class borrow; unknown ids fall
7
- // to the gpt-4o default. Render everything from here as ~est., never merged
8
- // with measured figures.
1
+ // EcoLogits v0.8.2 port; always render as ~est, never merged with measured.
9
2
 
10
3
  export interface ModelCarbon {
11
4
  activeB: number;
@@ -39,6 +32,9 @@ const GRIDS: Record<string, number> = {
39
32
  // Serving amortization: single-stream ceiling ÷ concurrency ≈ served impact.
40
33
  export const SERVING_CONCURRENCY = 32;
41
34
 
35
+ /** EcoLogits release this port tracks, so a UI can name it instead of guessing. */
36
+ export const ECOLOGITS_VERSION = '0.8.2';
37
+
42
38
  interface RegistryEntry {
43
39
  match?: string;
44
40
  exact?: string;
@@ -48,8 +44,7 @@ interface RegistryEntry {
48
44
  source: 'registry' | 'borrowed';
49
45
  }
50
46
 
51
- // Registry values verbatim from EcoLogits' model_repository; borrows cite
52
- // the public param disclosure they stand in for (MoE active/total).
47
+ // Registry values verbatim from EcoLogits' model_repository; borrows cite the public param disclosure they stand in for (MoE active/total).
53
48
  const TABLE: RegistryEntry[] = [
54
49
  { match: 'opus', active: 625.0, total: 2000, provider: 'anthropic', source: 'registry' },
55
50
  { match: 'sonnet', active: 137.5, total: 440, provider: 'anthropic', source: 'registry' },