@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
- // /ai-addons manual updater. Node built-ins only. RTK ships SHA256 checksums
2
- // with no signature, so checksum-only is the best available.
1
+ // /ai-addons manual updater. Node built-ins only. RTK ships SHA256 checksums with no signature, so checksum-only is the best available.
3
2
  import { execFileSync } from 'node:child_process';
4
3
  import { createHash } from 'node:crypto';
5
4
  import fs from 'node:fs/promises';
@@ -16,8 +15,7 @@ const RTK_BINARY = resolveRtkBinary();
16
15
  const CAVEMAN_LOCAL = path.join(EXTENSION_DIR, '..', 'caveman-session', 'rule.md');
17
16
  const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
18
17
  const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
19
- // Ponytail is a hoisted dependency, so the installed copy is found by walking
20
- // up rather than by naming a host directory.
18
+ // Ponytail is a hoisted dependency, so the installed copy is found by walking up rather than by naming a host directory.
21
19
  function ponytailLocal() {
22
20
  return findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'package.json');
23
21
  }
@@ -133,20 +131,76 @@ async function checkAddons(ctx) {
133
131
  }
134
132
  return lines.join('\n');
135
133
  }
134
+ // Ponytail is a hoisted dependency; replace its package dir from the npm tarball.
136
135
  async function updatePonytail(pi, ctx, dryRun = false) {
137
- // Bundled with tersio: `tersio update` pulls the copy with the CLI.
136
+ void pi;
137
+ const localPkg = ponytailLocal();
138
138
  let localVer = null;
139
139
  try {
140
- localVer = parsePackageVersion(await readTextIfExists(ponytailLocal() ?? ''));
140
+ localVer = localPkg ? parsePackageVersion(await readTextIfExists(localPkg)) : null;
141
141
  }
142
142
  catch {
143
143
  localVer = null;
144
144
  }
145
- void pi;
146
- const m = dryRun
147
- ? `Ponytail dry-run: bundled with tersio (local=${localVer || '—'}); run \`tersio update\` to refresh it.`
148
- : `Ponytail is bundled with tersio (local=${localVer || '—'}); run \`tersio update\` to refresh it.\n${RELOAD_MSG}`;
149
- return report(ctx, m, 'info');
145
+ if (!localPkg)
146
+ return report(ctx, 'Ponytail: package.json not found; reinstall with `tersio update`.', 'warning');
147
+ let meta;
148
+ try {
149
+ meta = await fetchJson('https://registry.npmjs.org/@dietrichgebert/ponytail/latest');
150
+ }
151
+ catch (e) {
152
+ return report(ctx, `Ponytail: cannot fetch npm metadata: ${e.message}`, 'warning');
153
+ }
154
+ const remoteVer = meta.version;
155
+ if (!remoteVer)
156
+ return report(ctx, 'Ponytail: npm metadata has no version.', 'warning');
157
+ if (localVer === remoteVer)
158
+ return report(ctx, `Ponytail up to date: local=${localVer} latest=${remoteVer}`, 'info');
159
+ if (!meta.dist?.tarball)
160
+ return report(ctx, 'Ponytail: npm metadata has no tarball.', 'warning');
161
+ if (dryRun)
162
+ return report(ctx, `Ponytail dry-run: would install ${remoteVer} over ${localVer || '—'} at ${path.dirname(localPkg)}.`, 'info');
163
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ponytail-update-'));
164
+ try {
165
+ const tarballPath = path.join(tmp, 'pkg.tgz');
166
+ notify(ctx, `Ponytail: downloading ${remoteVer}…`, 'info');
167
+ await httpsDownload(meta.dist.tarball, tarballPath);
168
+ if (meta.dist.shasum) {
169
+ const actual = createHash('sha1').update(await fs.readFile(tarballPath)).digest('hex');
170
+ if (actual !== meta.dist.shasum)
171
+ return report(ctx, `Ponytail: sha1 mismatch; expected ${meta.dist.shasum.slice(0, 12)}…, got ${actual.slice(0, 12)}…; not installing`, 'error');
172
+ }
173
+ const extractDir = path.join(tmp, 'out');
174
+ await fs.mkdir(extractDir, { recursive: true });
175
+ execFileSync('tar', ['xzf', tarballPath, '-C', extractDir], { encoding: 'utf8', shell: false });
176
+ const newPkgJson = await findFile(extractDir, 'package.json');
177
+ if (!newPkgJson)
178
+ return report(ctx, 'Ponytail: package.json not in tarball.', 'warning');
179
+ const newDir = path.dirname(newPkgJson);
180
+ const destDir = path.dirname(localPkg);
181
+ const backupDir = `${destDir}.bak`;
182
+ // fs.rename cannot cross filesystems (tmp vs home), so copy then verify.
183
+ await fs.rename(destDir, backupDir);
184
+ try {
185
+ await fs.cp(newDir, destDir, { recursive: true });
186
+ const written = parsePackageVersion(await readTextIfExists(path.join(destDir, 'package.json')));
187
+ if (written !== remoteVer)
188
+ throw new Error(`installed version ${written} !== ${remoteVer}`);
189
+ }
190
+ catch (e) {
191
+ await fs.rm(destDir, { recursive: true, force: true }).catch(() => { });
192
+ await fs.rename(backupDir, destDir).catch(() => { });
193
+ throw new Error(`${e.message}; restored backup`);
194
+ }
195
+ await fs.rm(backupDir, { recursive: true, force: true }).catch(() => { });
196
+ return report(ctx, `Ponytail updated → ${remoteVer} at ${destDir}\n${RELOAD_MSG}`, 'info');
197
+ }
198
+ catch (e) {
199
+ return report(ctx, `Ponytail update failed: ${e.message}`, 'warning');
200
+ }
201
+ finally {
202
+ fs.rm(tmp, { recursive: true, force: true }).catch(() => { });
203
+ }
150
204
  }
151
205
  async function updateRtk(ctx, dryRun = false) {
152
206
  let release;
@@ -192,14 +246,14 @@ async function updateRtk(ctx, dryRun = false) {
192
246
  const checks = await fs.readFile(checksPath, 'utf8');
193
247
  const expected = parseChecksum(checks, asset.name);
194
248
  if (!expected) {
195
- const m = `RTK: checksums.txt has no entry for ${asset.name}`;
196
- return report(ctx, m, 'warning');
249
+ const m = `RTK: checksums.txt has no entry for ${asset.name}; not installing`;
250
+ return report(ctx, m, 'error');
197
251
  }
198
252
  const archiveBuf = await fs.readFile(archivePath);
199
253
  const actual = createHash('sha256').update(archiveBuf).digest('hex').toLowerCase();
200
254
  if (actual !== expected) {
201
- const m = `RTK: checksum mismatch! expected=${expected.slice(0, 12)}… actual=${actual.slice(0, 12)}…`;
202
- return report(ctx, m, 'warning');
255
+ const m = `RTK: checksum mismatch! expected=${expected.slice(0, 12)}… actual=${actual.slice(0, 12)}…; not installing`;
256
+ return report(ctx, m, 'error');
203
257
  }
204
258
  notify(ctx, 'RTK: checksum verified.', 'info');
205
259
  const extractDir = path.join(tmp, 'extracted');
@@ -310,15 +364,16 @@ export default function aiAddonsUpdaterExtension(pi) {
310
364
  const cleanParts = parts.filter((p) => p !== '--dry-run' && p !== 'dry-run');
311
365
  const sub = cleanParts[0];
312
366
  if (sub === 'check' || sub === 'status') {
313
- const summary = await checkAddons(ctx);
367
+ await checkAddons(ctx);
314
368
  notify(ctx, 'ai-addons check complete.', 'info');
315
- return summary;
369
+ return;
316
370
  }
317
371
  if (sub === 'update' && cleanParts[1]) {
318
- return runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
372
+ await runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
373
+ return;
319
374
  }
320
- const m = 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]';
321
- return report(ctx, m, 'warning');
375
+ report(ctx, 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]', 'warning');
376
+ return;
322
377
  },
323
378
  });
324
379
  }
@@ -1,5 +1,4 @@
1
- // /ai-addons manual updater. Node built-ins only. RTK ships SHA256 checksums
2
- // with no signature, so checksum-only is the best available.
1
+ // /ai-addons manual updater. Node built-ins only. RTK ships SHA256 checksums with no signature, so checksum-only is the best available.
3
2
 
4
3
  import { execFileSync } from 'node:child_process';
5
4
  import { createHash } from 'node:crypto';
@@ -37,8 +36,7 @@ const CAVEMAN_LOCAL = path.join(EXTENSION_DIR, '..', 'caveman-session', 'rule.md
37
36
  const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
38
37
  const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
39
38
 
40
- // Ponytail is a hoisted dependency, so the installed copy is found by walking
41
- // up rather than by naming a host directory.
39
+ // Ponytail is a hoisted dependency, so the installed copy is found by walking up rather than by naming a host directory.
42
40
  function ponytailLocal(): string | null {
43
41
  return findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'package.json');
44
42
  }
@@ -54,12 +52,12 @@ interface AddonUpdaterCtx {
54
52
 
55
53
  interface AddonUpdaterPi {
56
54
  setLabel?: (label: string) => void;
57
- registerCommand?: (name: string, config: { description: string; handler: (args: string, ctx: AddonUpdaterCtx) => Promise<string> }) => void;
55
+ registerCommand?: (name: string, config: { description: string; handler: (args: string, ctx: AddonUpdaterCtx) => Promise<void> }) => void;
58
56
  exec?: (cmd: string, args: string[], opts?: { cwd?: string }) => Promise<{ stdout: string; stderr: string; code: number }>;
59
57
  cwd?: string;
60
58
  }
61
59
 
62
- type NotifyLevel = 'info' | 'warning';
60
+ type NotifyLevel = 'info' | 'warning' | 'error';
63
61
 
64
62
  function notify(ctx: AddonUpdaterCtx | undefined, msg: string, level: NotifyLevel): void {
65
63
  ctx?.ui?.notify?.(String(msg), level);
@@ -180,17 +178,62 @@ async function checkAddons(ctx: AddonUpdaterCtx): Promise<string> {
180
178
  return lines.join('\n');
181
179
  }
182
180
 
181
+ // Ponytail is a hoisted dependency; replace its package dir from the npm tarball.
183
182
  async function updatePonytail(pi: AddonUpdaterPi, ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
184
- // Bundled with tersio: `tersio update` pulls the copy with the CLI.
183
+ void pi;
184
+ const localPkg = ponytailLocal();
185
185
  let localVer: string | null = null;
186
+ try { localVer = localPkg ? parsePackageVersion(await readTextIfExists(localPkg)) : null; } catch { localVer = null; }
187
+ if (!localPkg) return report(ctx, 'Ponytail: package.json not found; reinstall with `tersio update`.', 'warning');
188
+
189
+ let meta: { version?: string; dist?: { tarball?: string; shasum?: string } };
186
190
  try {
187
- localVer = parsePackageVersion(await readTextIfExists(ponytailLocal() ?? ''));
188
- } catch { localVer = null; }
189
- void pi;
190
- const m = dryRun
191
- ? `Ponytail dry-run: bundled with tersio (local=${localVer || '—'}); run \`tersio update\` to refresh it.`
192
- : `Ponytail is bundled with tersio (local=${localVer || '—'}); run \`tersio update\` to refresh it.\n${RELOAD_MSG}`;
193
- return report(ctx, m, 'info');
191
+ meta = await fetchJson('https://registry.npmjs.org/@dietrichgebert/ponytail/latest');
192
+ } catch (e) {
193
+ return report(ctx, `Ponytail: cannot fetch npm metadata: ${(e as Error).message}`, 'warning');
194
+ }
195
+ const remoteVer = meta.version;
196
+ if (!remoteVer) return report(ctx, 'Ponytail: npm metadata has no version.', 'warning');
197
+ if (localVer === remoteVer) return report(ctx, `Ponytail up to date: local=${localVer} latest=${remoteVer}`, 'info');
198
+ if (!meta.dist?.tarball) return report(ctx, 'Ponytail: npm metadata has no tarball.', 'warning');
199
+
200
+ if (dryRun) return report(ctx, `Ponytail dry-run: would install ${remoteVer} over ${localVer || '—'} at ${path.dirname(localPkg)}.`, 'info');
201
+
202
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ponytail-update-'));
203
+ try {
204
+ const tarballPath = path.join(tmp, 'pkg.tgz');
205
+ notify(ctx, `Ponytail: downloading ${remoteVer}…`, 'info');
206
+ await httpsDownload(meta.dist.tarball, tarballPath);
207
+ if (meta.dist.shasum) {
208
+ const actual = createHash('sha1').update(await fs.readFile(tarballPath)).digest('hex');
209
+ if (actual !== meta.dist.shasum) return report(ctx, `Ponytail: sha1 mismatch; expected ${meta.dist.shasum.slice(0, 12)}…, got ${actual.slice(0, 12)}…; not installing`, 'error');
210
+ }
211
+ const extractDir = path.join(tmp, 'out');
212
+ await fs.mkdir(extractDir, { recursive: true });
213
+ execFileSync('tar', ['xzf', tarballPath, '-C', extractDir], { encoding: 'utf8', shell: false });
214
+ const newPkgJson = await findFile(extractDir, 'package.json');
215
+ if (!newPkgJson) return report(ctx, 'Ponytail: package.json not in tarball.', 'warning');
216
+ const newDir = path.dirname(newPkgJson);
217
+ const destDir = path.dirname(localPkg);
218
+ const backupDir = `${destDir}.bak`;
219
+ // fs.rename cannot cross filesystems (tmp vs home), so copy then verify.
220
+ await fs.rename(destDir, backupDir);
221
+ try {
222
+ await fs.cp(newDir, destDir, { recursive: true });
223
+ const written = parsePackageVersion(await readTextIfExists(path.join(destDir, 'package.json')));
224
+ if (written !== remoteVer) throw new Error(`installed version ${written} !== ${remoteVer}`);
225
+ } catch (e) {
226
+ await fs.rm(destDir, { recursive: true, force: true }).catch(() => { });
227
+ await fs.rename(backupDir, destDir).catch(() => { });
228
+ throw new Error(`${(e as Error).message}; restored backup`);
229
+ }
230
+ await fs.rm(backupDir, { recursive: true, force: true }).catch(() => { });
231
+ return report(ctx, `Ponytail updated → ${remoteVer} at ${destDir}\n${RELOAD_MSG}`, 'info');
232
+ } catch (e) {
233
+ return report(ctx, `Ponytail update failed: ${(e as Error).message}`, 'warning');
234
+ } finally {
235
+ fs.rm(tmp, { recursive: true, force: true }).catch(() => { });
236
+ }
194
237
  }
195
238
 
196
239
  async function updateRtk(ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
@@ -240,14 +283,14 @@ async function updateRtk(ctx: AddonUpdaterCtx, dryRun = false): Promise<string>
240
283
  const checks = await fs.readFile(checksPath, 'utf8');
241
284
  const expected = parseChecksum(checks, asset.name);
242
285
  if (!expected) {
243
- const m = `RTK: checksums.txt has no entry for ${asset.name}`;
244
- return report(ctx, m, 'warning');
286
+ const m = `RTK: checksums.txt has no entry for ${asset.name}; not installing`;
287
+ return report(ctx, m, 'error');
245
288
  }
246
289
  const archiveBuf = await fs.readFile(archivePath);
247
290
  const actual = createHash('sha256').update(archiveBuf).digest('hex').toLowerCase();
248
291
  if (actual !== expected) {
249
- const m = `RTK: checksum mismatch! expected=${expected.slice(0, 12)}… actual=${actual.slice(0, 12)}…`;
250
- return report(ctx, m, 'warning');
292
+ const m = `RTK: checksum mismatch! expected=${expected.slice(0, 12)}… actual=${actual.slice(0, 12)}…; not installing`;
293
+ return report(ctx, m, 'error');
251
294
  }
252
295
  notify(ctx, 'RTK: checksum verified.', 'info');
253
296
 
@@ -355,15 +398,16 @@ export default function aiAddonsUpdaterExtension(pi: AddonUpdaterPi): void {
355
398
  const sub = cleanParts[0];
356
399
 
357
400
  if (sub === 'check' || sub === 'status') {
358
- const summary = await checkAddons(ctx);
401
+ await checkAddons(ctx);
359
402
  notify(ctx, 'ai-addons check complete.', 'info');
360
- return summary;
403
+ return;
361
404
  }
362
405
  if (sub === 'update' && cleanParts[1]) {
363
- return runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
406
+ await runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
407
+ return;
364
408
  }
365
- const m = 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]';
366
- return report(ctx, m, 'warning');
409
+ report(ctx, 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]', 'warning');
410
+ return;
367
411
  },
368
412
  });
369
413
  }
@@ -1,45 +1,26 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { activeModesSummary, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.js';
3
+ import { getSharedComboState, hasModeState, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode, systemPromptIncludes } from '../shared/session-state.js';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.js';
6
+ import { announceStatus } from '../shared/status.js';
6
7
  import { readCavemanDefault } from '../shared/plugin-settings.js';
7
8
  const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
8
9
  const RULE_PATH = join(CAVERN_DIR, 'rule.md');
9
- const FALLBACK_FULL_RULE = `Caveman full active for this session.
10
- Respond terse like smart caveman. Keep all technical substance. Drop filler, pleasantries, and hedging.
11
-
12
- Rules:
13
- - Drop articles where meaning stays clear. Keep fragments and short sentences.
14
- - Keep technical terms, code, commands, paths, API names, numbers, units, and errors exact.
15
- - Never invent abbreviations or causal arrows. Do not add words to sound caveman.
16
- - Use ASD-STE100 Simplified Technical English. Keep one idea per sentence, target 20 words, active voice, and consistent terms.
17
- - Make no tool-call narration. Use no decorative tables or emoji.
18
- - Preserve the user's reply language. Compress style, not language.
19
- - Drop caveman for security warnings, irreversible actions, ambiguous multi-step sequences, or requests to clarify.
20
- - Write code, comments, commits, docs, issues, pull requests, and third-party messages in normal prose.
21
-
22
- Default: **full**. Switch: \`/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off\`. Stop: "stop caveman" or "normal mode".`;
23
- // ponytail: synchronous read each full-mode injection; ceiling = small file, cold session start. Upgrade path: cache file contents + mtime, invalidate on change.
10
+ // Fail loud: a wrong rule reads to the model as the real one.
24
11
  function readFullRule() {
25
12
  try {
26
13
  return readFileSync(RULE_PATH, 'utf8');
27
14
  }
28
15
  catch {
29
- return FALLBACK_FULL_RULE;
16
+ return 'Caveman full requested, but rule.md is missing. Run: tersio doctor --fix extensions';
30
17
  }
31
18
  }
32
19
  const DEFAULT_MODE = 'off';
33
- const INSTRUCTIONS = {
34
- lite: `Caveman lite active for this session.
35
- Respond concise. Drop pleasantries, filler, and hedging. Keep complete technical substance. Code, commands, paths, errors, commits, and PR text stay normal/exact.`,
36
- full: () => `Caveman full active for this session.\n${readFullRule()}`,
37
- ultra: `Caveman ultra active for this session.
38
- Maximum terse prose. Strip conjunctions only when meaning stays clear. State each fact once. Use no invented abbreviations or causal arrows. Keep code, commands, commits, PR text, paths, API names, numbers, units, and errors exact. Drop caveman for security warnings, irreversible actions, ambiguous multi-step sequences, or user confusion.`,
39
- 'wenyan-lite': `Caveman wenyan-lite active for this session. Use semi-classical terse prose. Keep grammar structure, user language, technical terms, code, commands, paths, API names, numbers, units, and errors exact. Drop caveman when clarity or safety needs normal prose.`,
40
- 'wenyan-full': `Caveman wenyan-full active for this session. Use maximum classical terseness where meaning stays clear. Use classical sentence patterns, verbs before objects, and optional omitted subjects. Keep user language, technical terms, code, commands, paths, API names, numbers, units, and errors exact. Drop caveman when clarity or safety needs normal prose.`,
41
- 'wenyan-ultra': `Caveman wenyan-ultra active for this session. Use extreme classical abbreviation while keeping a classical Chinese feel and meaning clear. Keep user language, technical terms, code, commands, paths, API names, numbers, units, and errors exact. Drop caveman when clarity or safety needs normal prose.`,
42
- };
20
+ // Upstream ships one SKILL.md; the level is a directive inside it, so swap only the header.
21
+ function buildInstruction(mode) {
22
+ return `Caveman ${mode} active for this session.\n${readFullRule()}`;
23
+ }
43
24
  function resolveMode(entries, fallback = DEFAULT_MODE) {
44
25
  return lastCustomValue(entries, 'caveman-mode', (data) => normalizeMode('caveman', data?.mode)) ?? fallback;
45
26
  }
@@ -49,20 +30,7 @@ function isOffCommand(text) {
49
30
  }
50
31
  export default function cavemanSessionExtension(pi) {
51
32
  let currentMode = DEFAULT_MODE;
52
- let isActive = false;
53
- let lastCtx = undefined;
54
- function syncStatus(ctx) {
55
- lastCtx = paintableCtx(lastCtx, ctx);
56
- const c = lastCtx;
57
- if (!c?.ui?.setStatus)
58
- return;
59
- // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
60
- if (isComboPresetActive() || currentMode === 'off') {
61
- c.ui.setStatus('caveman', undefined);
62
- return;
63
- }
64
- paintStatusBar(c.ui, 'caveman', '🪨', `caveman: ${currentMode.toUpperCase()}`, isActive);
65
- }
33
+ let lastInjected = undefined;
66
34
  function setMode(mode, ctx) {
67
35
  const normalized = normalizeMode('caveman', mode);
68
36
  if (!normalized)
@@ -70,26 +38,17 @@ export default function cavemanSessionExtension(pi) {
70
38
  currentMode = normalized;
71
39
  pi.appendEntry?.('caveman-mode', { mode: normalized });
72
40
  setSharedComboMode('caveman', normalized);
73
- syncStatus(ctx);
74
- const active = activeModesSummary(getSharedComboState());
75
- const msg = normalized === 'off'
76
- ? `Caveman off. Active: ${active}.`
77
- : `Caveman ${normalized} on — terse replies for this session. Active: ${active}.`;
78
- ctx?.ui?.notify?.(msg, 'info');
41
+ announceStatus(ctx);
79
42
  return true;
80
43
  }
81
44
  setExtensionLabel(pi, 'Caveman session toggle');
82
- // Live mirror: a /tersio or /combo switch publishes shared state — adopt
83
- // it at once so the next turn injects the new mode with no session reload.
84
- // Stable identity, so the bridge set dedupes across re-inits.
45
+ // Adopt a /tersio or /combo switch at once, so the next turn needs no reload.
85
46
  function syncFromShared(state) {
86
47
  const mode = normalizeMode('caveman', state.caveman);
87
- if (mode) {
48
+ if (mode)
88
49
  currentMode = mode;
89
- syncStatus();
90
- }
91
50
  }
92
- setSharedComboListener(syncFromShared);
51
+ setSharedComboListener('caveman', syncFromShared);
93
52
  pi.registerCommand?.('caveman', {
94
53
  description: 'Toggle terse caveman replies for this session',
95
54
  handler: async (args, ctx) => {
@@ -99,7 +58,7 @@ export default function cavemanSessionExtension(pi) {
99
58
  return;
100
59
  }
101
60
  if (arg === 'status') {
102
- ctx?.ui?.notify?.(`Caveman: ${currentMode}`, 'info');
61
+ announceStatus(ctx);
103
62
  return;
104
63
  }
105
64
  if (!setMode(arg, ctx)) {
@@ -115,20 +74,18 @@ export default function cavemanSessionExtension(pi) {
115
74
  });
116
75
  function restoreMode(ctx) {
117
76
  const entries = sessionEntries(ctx);
118
- // Publish the persisted combo state (incl. combo-level) before painting:
119
- // bar suppression reads the in-process bridge, which is empty in a fresh
120
- // host until the combo extension reconciles — and its reconcile is
121
- // UI-gated. Deriving it here makes suppression independent of load order.
122
- reconcileSharedComboEntries(entries);
123
- // Persisted session state wins; a fresh session falls back to the
124
- // installer/user-configured default (off unless configured).
77
+ // A subagent's branch has no Tersio entries; reconciling it would wipe shared state.
78
+ if (hasModeState(entries))
79
+ reconcileSharedComboEntries(entries);
80
+ // Persisted state wins, then a mode already chosen this session, then the configured default. `off` is the start value, not "unset".
125
81
  const persisted = resolveMode(entries, '');
126
- currentMode = persisted || normalizeMode('caveman', readCavemanDefault()) || DEFAULT_MODE;
127
- syncStatus(ctx);
82
+ const alreadyChosen = currentMode !== DEFAULT_MODE;
83
+ currentMode = persisted || (alreadyChosen ? currentMode : normalizeMode('caveman', readCavemanDefault())) || DEFAULT_MODE;
84
+ announceStatus(ctx);
128
85
  }
86
+ // Start, resume, and branch all land here, so every session type shows it.
129
87
  pi.on('session_start', async (_event, ctx) => {
130
88
  restoreMode(ctx);
131
- ctx?.ui?.notify?.(`Caveman loaded: ${currentMode}`, 'info');
132
89
  });
133
90
  onHostEvent(pi, 'session_branch', async (_event, ctx) => {
134
91
  restoreMode(ctx);
@@ -136,22 +93,23 @@ export default function cavemanSessionExtension(pi) {
136
93
  pi.on('session_tree', async (_event, ctx) => {
137
94
  restoreMode(ctx);
138
95
  });
139
- pi.on('agent_start', async (_event, ctx) => {
140
- isActive = true;
141
- syncStatus(ctx);
96
+ onHostEvent(pi, 'session_switch', async (_event, ctx) => {
97
+ restoreMode(ctx);
142
98
  });
143
- pi.on('agent_end', async (_event, ctx) => {
144
- isActive = false;
145
- syncStatus(ctx);
99
+ onHostEvent(pi, 'session_compact', async (_event, ctx) => {
100
+ restoreMode(ctx);
146
101
  });
147
102
  pi.on('before_agent_start', async (event) => {
148
103
  const mode = isOmpSubagentPrompt(event.systemPrompt) ? getSharedComboState().caveman : currentMode;
149
104
  if (!mode || mode === 'off')
150
105
  return;
151
- const def = INSTRUCTIONS[mode];
152
- if (!def)
106
+ const instruction = buildInstruction(mode);
107
+ // Match the whole instruction so a re-presented prompt cannot stack it.
108
+ if (systemPromptIncludes(event.systemPrompt, instruction))
153
109
  return;
154
- const instruction = typeof def === 'function' ? def() : def;
155
- return injectPromptText(pi, event, instruction);
110
+ // The previous level's block is replaced, not stacked on.
111
+ const stale = lastInjected;
112
+ lastInjected = instruction;
113
+ return injectPromptText(pi, event, instruction, stale);
156
114
  });
157
115
  }