@krtclcdy/tersio 2.25.5 → 2.25.7

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 (231) hide show
  1. package/README.md +42 -38
  2. package/dashboard/dist/index.html +15 -15
  3. package/dist/bench/code-out/pfull-0/debounce.js +13 -0
  4. package/dist/bench/code-out/pfull-0/deepClone.js +18 -0
  5. package/dist/bench/code-out/pfull-0/groupBy.js +15 -0
  6. package/dist/bench/code-out/pfull-1/debounce.js +13 -0
  7. package/dist/bench/code-out/pfull-1/deepClone.js +17 -0
  8. package/dist/bench/code-out/pfull-1/groupBy.js +14 -0
  9. package/dist/bench/code-out/pfull-2/debounce.js +13 -0
  10. package/dist/bench/code-out/pfull-2/deepClone.js +18 -0
  11. package/dist/bench/code-out/pfull-2/groupBy.js +15 -0
  12. package/dist/bench/code-out/pfull-3/debounce.js +12 -0
  13. package/dist/bench/code-out/pfull-3/deepClone.js +17 -0
  14. package/dist/bench/code-out/pfull-3/groupBy.js +7 -0
  15. package/dist/bench/code-out/pfull-4/debounce.js +10 -0
  16. package/dist/bench/code-out/pfull-4/deepClone.js +17 -0
  17. package/dist/bench/code-out/pfull-4/groupBy.js +14 -0
  18. package/dist/bench/code-out/plite-0/debounce.js +13 -0
  19. package/dist/bench/code-out/plite-0/deepClone.js +18 -0
  20. package/dist/bench/code-out/plite-0/groupBy.js +15 -0
  21. package/dist/bench/code-out/plite-1/debounce.js +13 -0
  22. package/dist/bench/code-out/plite-1/deepClone.js +17 -0
  23. package/dist/bench/code-out/plite-1/groupBy.js +11 -0
  24. package/dist/bench/code-out/plite-2/debounce.js +12 -0
  25. package/dist/bench/code-out/plite-2/deepClone.js +17 -0
  26. package/dist/bench/code-out/plite-2/groupBy.js +14 -0
  27. package/dist/bench/code-out/plite-3/debounce.js +12 -0
  28. package/dist/bench/code-out/plite-3/deepClone.js +17 -0
  29. package/dist/bench/code-out/plite-3/groupBy.js +14 -0
  30. package/dist/bench/code-out/plite-4/debounce.js +12 -0
  31. package/dist/bench/code-out/plite-4/deepClone.js +17 -0
  32. package/dist/bench/code-out/plite-4/groupBy.js +14 -0
  33. package/dist/bench/code-out/pultra-0/debounce.js +12 -0
  34. package/dist/bench/code-out/pultra-0/deepClone.js +20 -0
  35. package/dist/bench/code-out/pultra-0/groupBy.js +14 -0
  36. package/dist/bench/code-out/pultra-1/debounce.js +10 -0
  37. package/dist/bench/code-out/pultra-1/deepClone.js +17 -0
  38. package/dist/bench/code-out/pultra-1/groupBy.js +7 -0
  39. package/dist/bench/code-out/pultra-2/debounce.js +13 -0
  40. package/dist/bench/code-out/pultra-2/deepClone.js +18 -0
  41. package/dist/bench/code-out/pultra-2/groupBy.js +15 -0
  42. package/dist/bench/code-out/pultra-3/debounce.js +13 -0
  43. package/dist/bench/code-out/pultra-3/deepClone.js +18 -0
  44. package/dist/bench/code-out/pultra-3/groupBy.js +15 -0
  45. package/dist/bench/code-out/pultra-4/debounce.js +13 -0
  46. package/dist/bench/code-out/pultra-4/deepClone.js +18 -0
  47. package/dist/bench/code-out/pultra-4/groupBy.js +15 -0
  48. package/dist/bench/code-out/run-0/debounce.js +12 -0
  49. package/dist/bench/code-out/run-0/deepClone.js +17 -0
  50. package/dist/bench/code-out/run-0/groupBy.js +14 -0
  51. package/dist/bench/code-out/run-1/debounce.js +13 -0
  52. package/dist/bench/code-out/run-1/deepClone.js +15 -0
  53. package/dist/bench/code-out/run-1/groupBy.js +13 -0
  54. package/dist/bench/code-out/run-2/debounce.js +12 -0
  55. package/dist/bench/code-out/run-2/deepClone.js +18 -0
  56. package/dist/bench/code-out/run-2/groupBy.js +13 -0
  57. package/dist/bench/code-out/run-3/debounce.js +12 -0
  58. package/dist/bench/code-out/run-3/deepClone.js +18 -0
  59. package/dist/bench/code-out/run-3/groupBy.js +14 -0
  60. package/dist/bench/code-out/run-4/debounce.js +12 -0
  61. package/dist/bench/code-out/run-4/deepClone.js +16 -0
  62. package/dist/bench/code-out/run-4/groupBy.js +13 -0
  63. package/dist/cli/backups.js +7 -14
  64. package/dist/cli/banner.js +3 -3
  65. package/dist/cli/common.js +29 -13
  66. package/dist/cli/currency.js +1 -1
  67. package/dist/cli/dashboard.js +66 -35
  68. package/dist/cli/doctor-fix.js +204 -19
  69. package/dist/cli/doctor.js +263 -40
  70. package/dist/cli/hosts.js +25 -6
  71. package/dist/cli/install.js +150 -49
  72. package/dist/cli/interactive.js +3 -3
  73. package/dist/cli/manifest.js +109 -9
  74. package/dist/cli/profile.js +13 -8
  75. package/dist/cli/rtk-wiring.js +1 -1
  76. package/dist/cli/settings.js +20 -5
  77. package/dist/cli/uninstall.js +4 -4
  78. package/dist/cli/update.js +6 -7
  79. package/dist/cli/usage.js +6 -9
  80. package/dist/extensions/caveman/index.js +10 -4
  81. package/dist/extensions/caveman/rule-lite.md +77 -0
  82. package/dist/extensions/caveman/rule-megacave-lite.md +59 -0
  83. package/dist/extensions/caveman/rule-megacave-ultra.md +59 -0
  84. package/dist/extensions/caveman/rule-megacave.md +5 -3
  85. package/dist/extensions/caveman/rule-ultra.md +4 -4
  86. package/dist/extensions/caveman/rule.md +55 -45
  87. package/dist/extensions/combo/index.js +85 -25
  88. package/dist/extensions/commands/index.js +1 -1
  89. package/dist/extensions/pstack/index.js +121 -0
  90. package/dist/extensions/pstack/rule.md +141 -0
  91. package/dist/extensions/rtk/filter.js +0 -1
  92. package/dist/extensions/rtk/session.js +20 -12
  93. package/dist/extensions/server.js +90 -6
  94. package/dist/extensions/shared/agent-stats.js +214 -278
  95. package/dist/extensions/shared/brand-marks.js +33 -34
  96. package/dist/extensions/shared/carbon.js +1 -1
  97. package/dist/extensions/shared/plugin-settings.js +11 -6
  98. package/dist/extensions/shared/pricing.js +8 -16
  99. package/dist/extensions/shared/rtk-eligible.js +84 -0
  100. package/dist/extensions/shared/rtk-gain.js +5 -10
  101. package/dist/extensions/shared/session-state.js +15 -10
  102. package/dist/extensions/shared/sqlite.js +162 -0
  103. package/dist/extensions/shared/status.js +8 -9
  104. package/dist/extensions/shared/types.js +1 -1
  105. package/dist/extensions/shared/usage-ledger.js +155 -61
  106. package/dist/extensions/shared/usage-store.js +111 -52
  107. package/dist/extensions/shared/utils.js +5 -5
  108. package/dist/extensions/timing/index.js +31 -0
  109. package/dist/extensions/updater/index.js +3 -6
  110. package/dist/tersio.js +8 -3
  111. package/extensions/caveman/index.ts +10 -4
  112. package/extensions/caveman/rule-lite.md +77 -0
  113. package/extensions/caveman/rule-megacave-lite.md +59 -0
  114. package/extensions/caveman/rule-megacave-ultra.md +59 -0
  115. package/extensions/caveman/rule-megacave.md +5 -3
  116. package/extensions/caveman/rule-ultra.md +4 -4
  117. package/extensions/caveman/rule.md +55 -45
  118. package/extensions/combo/index.ts +86 -23
  119. package/extensions/commands/index.ts +1 -1
  120. package/extensions/pstack/index.ts +130 -0
  121. package/extensions/pstack/playbooks/authoring-a-skill.md +12 -0
  122. package/extensions/pstack/playbooks/autonomous-run.md +13 -0
  123. package/extensions/pstack/playbooks/autopilot-full.md +13 -0
  124. package/extensions/pstack/playbooks/autopilot-stack.md +16 -0
  125. package/extensions/pstack/playbooks/babysit.md +23 -0
  126. package/extensions/pstack/playbooks/bug-fix.md +15 -0
  127. package/extensions/pstack/playbooks/eval.md +25 -0
  128. package/extensions/pstack/playbooks/feature.md +21 -0
  129. package/extensions/pstack/playbooks/hillclimb.md +21 -0
  130. package/extensions/pstack/playbooks/investigation.md +14 -0
  131. package/extensions/pstack/playbooks/multi-phase-plan.md +155 -0
  132. package/extensions/pstack/playbooks/opening-a-pr.md +34 -0
  133. package/extensions/pstack/playbooks/orchestrate.md +111 -0
  134. package/extensions/pstack/playbooks/pause-safely.md +10 -0
  135. package/extensions/pstack/playbooks/perf-issue.md +25 -0
  136. package/extensions/pstack/playbooks/prototype.md +14 -0
  137. package/extensions/pstack/playbooks/refactoring.md +16 -0
  138. package/extensions/pstack/playbooks/runtime-forensics.md +11 -0
  139. package/extensions/pstack/playbooks/session-pickup.md +11 -0
  140. package/extensions/pstack/playbooks/shipping.md +17 -0
  141. package/extensions/pstack/playbooks/trace-forensics.md +14 -0
  142. package/extensions/pstack/playbooks/visual-parity.md +11 -0
  143. package/extensions/pstack/playbooks/worktree-cleanup.md +12 -0
  144. package/extensions/pstack/principles/attack-the-premise.md +22 -0
  145. package/extensions/pstack/principles/boundary-discipline.md +33 -0
  146. package/extensions/pstack/principles/build-the-lever.md +22 -0
  147. package/extensions/pstack/principles/encode-lessons-in-structure.md +10 -0
  148. package/extensions/pstack/principles/exhaust-the-design-space.md +8 -0
  149. package/extensions/pstack/principles/experience-first.md +18 -0
  150. package/extensions/pstack/principles/explain-the-number.md +16 -0
  151. package/extensions/pstack/principles/fix-root-causes.md +23 -0
  152. package/extensions/pstack/principles/foundational-thinking.md +20 -0
  153. package/extensions/pstack/principles/guard-the-context-window.md +11 -0
  154. package/extensions/pstack/principles/laziness-protocol.md +9 -0
  155. package/extensions/pstack/principles/make-operations-idempotent.md +23 -0
  156. package/extensions/pstack/principles/migrate-callers-then-delete-legacy-apis.md +21 -0
  157. package/extensions/pstack/principles/minimize-reader-load.md +17 -0
  158. package/extensions/pstack/principles/model-the-domain.md +23 -0
  159. package/extensions/pstack/principles/never-block-on-the-human.md +14 -0
  160. package/extensions/pstack/principles/outcome-oriented-execution.md +20 -0
  161. package/extensions/pstack/principles/prove-it-works.md +21 -0
  162. package/extensions/pstack/principles/redesign-from-first-principles.md +15 -0
  163. package/extensions/pstack/principles/separate-before-serializing-shared-state.md +15 -0
  164. package/extensions/pstack/principles/sequence-verifiable-units.md +16 -0
  165. package/extensions/pstack/principles/subtract-before-you-add.md +17 -0
  166. package/extensions/pstack/principles/test-behavior-not-implementation.md +20 -0
  167. package/extensions/pstack/principles/type-system-discipline.md +17 -0
  168. package/extensions/pstack/references/bugbot-triage.md +142 -0
  169. package/extensions/pstack/rule.md +141 -0
  170. package/extensions/pstack/skills/architect/SKILL.md +78 -0
  171. package/extensions/pstack/skills/architect/references/design-red-flags.md +57 -0
  172. package/extensions/pstack/skills/architect/references/rationale-template.md +35 -0
  173. package/extensions/pstack/skills/architect/references/runner-prompt.md +20 -0
  174. package/extensions/pstack/skills/arena/SKILL.md +47 -0
  175. package/extensions/pstack/skills/benchmark-checklist/SKILL.md +38 -0
  176. package/extensions/pstack/skills/blast-radius/SKILL.md +49 -0
  177. package/extensions/pstack/skills/bro/SKILL.md +6 -0
  178. package/extensions/pstack/skills/correct/SKILL.md +31 -0
  179. package/extensions/pstack/skills/figure-it-out/SKILL.md +51 -0
  180. package/extensions/pstack/skills/how/SKILL.md +35 -0
  181. package/extensions/pstack/skills/how/references/explainer-prompt.md +55 -0
  182. package/extensions/pstack/skills/how/references/explorer-prompt.md +35 -0
  183. package/extensions/pstack/skills/interrogate/SKILL.md +73 -0
  184. package/extensions/pstack/skills/interrogate/references/code-quality-review.md +47 -0
  185. package/extensions/pstack/skills/interrogate/references/lead-judgment.md +58 -0
  186. package/extensions/pstack/skills/interrogate/references/reviewer-prompt.md +62 -0
  187. package/extensions/pstack/skills/interrogate/references/rubric.md +77 -0
  188. package/extensions/pstack/skills/no-comments/SKILL.md +23 -0
  189. package/extensions/pstack/skills/reflect/SKILL.md +66 -0
  190. package/extensions/pstack/skills/reflect/references/divergent-reviewer.md +38 -0
  191. package/extensions/pstack/skills/reflect/references/judgment-reviewer.md +37 -0
  192. package/extensions/pstack/skills/reflect/references/synthesizer.md +56 -0
  193. package/extensions/pstack/skills/reflect/references/tooling-reviewer.md +48 -0
  194. package/extensions/pstack/skills/show-me-your-work/SKILL.md +81 -0
  195. package/extensions/pstack/skills/swarm/SKILL.md +45 -0
  196. package/extensions/pstack/skills/tdd/SKILL.md +41 -0
  197. package/extensions/pstack/skills/technical-writing/SKILL.md +105 -0
  198. package/extensions/pstack/skills/unslop/SKILL.md +61 -0
  199. package/extensions/pstack/skills/why/SKILL.md +97 -0
  200. package/extensions/pstack/skills/why/references/epistemics.md +71 -0
  201. package/extensions/pstack/skills/why/references/investigator-prompt.md +84 -0
  202. package/extensions/pstack/skills/why/references/source-playbook.md +17 -0
  203. package/extensions/pstack/skills/why/references/sources/code-archaeology.md +51 -0
  204. package/extensions/pstack/skills/why/references/sources/databricks.md +70 -0
  205. package/extensions/pstack/skills/why/references/sources/datadog.md +97 -0
  206. package/extensions/pstack/skills/why/references/sources/incident-postmortem.md +5 -0
  207. package/extensions/pstack/skills/why/references/sources/linear.md +48 -0
  208. package/extensions/pstack/skills/why/references/sources/notion.md +55 -0
  209. package/extensions/pstack/skills/why/references/sources/sentry.md +66 -0
  210. package/extensions/pstack/skills/why/references/sources/slack.md +17 -0
  211. package/extensions/pstack/skills/why/references/synthesizer-prompt.md +129 -0
  212. package/extensions/rtk/filter.ts +0 -1
  213. package/extensions/rtk/session.ts +19 -11
  214. package/extensions/server.ts +72 -7
  215. package/extensions/shared/agent-stats.ts +226 -278
  216. package/extensions/shared/brand-marks.ts +34 -34
  217. package/extensions/shared/carbon.ts +1 -1
  218. package/extensions/shared/plugin-settings.ts +12 -6
  219. package/extensions/shared/pricing.ts +10 -26
  220. package/extensions/shared/rtk-eligible.ts +83 -0
  221. package/extensions/shared/rtk-gain.ts +5 -10
  222. package/extensions/shared/session-state.ts +18 -13
  223. package/extensions/shared/sqlite.ts +162 -0
  224. package/extensions/shared/status.ts +9 -9
  225. package/extensions/shared/types.ts +2 -1
  226. package/extensions/shared/usage-ledger.ts +157 -66
  227. package/extensions/shared/usage-store.ts +120 -52
  228. package/extensions/shared/utils.ts +5 -5
  229. package/extensions/timing/index.ts +44 -0
  230. package/extensions/updater/index.ts +3 -8
  231. package/package.json +21 -9
@@ -1,10 +1,4 @@
1
- // Brand mark catalog: names, not bytes. One list serves both consumers — the dashboard draws from
2
- // it at runtime, and the CLI export inlines the same URLs so a single-file dashboard keeps its
3
- // logos with the network off. Simple Icons files are CC0-1.0; the marks stay their owners'
4
- // property and the dashboard uses them nominatively, to name the service that served a request.
5
- // A vendor with no Simple Icons entry keeps its owner-hosted mark, one with no hosted mark is
6
- // drawn inline (`dashboard/app/src/lib/marks/inline.ts`), and bumping `SIMPLE_ICONS` once moves
7
- // every Simple Icons entry with it.
1
+ // Brand mark catalog: names not bytes; Simple Icons files are CC0-1.0, marks remain their owners'.
8
2
  const SIMPLE_ICONS = "https://cdn.jsdelivr.net/npm/simple-icons@16/icons";
9
3
 
10
4
  /** Slug to mark URL. Only slugs named here resolve to a mark; the rest get a monogram. */
@@ -34,6 +28,8 @@ export const REMOTE_MARKS: Readonly<Record<string, string>> = {
34
28
  // Owner-hosted, nominative use.
35
29
  groq: "https://groq.com/favicon.svg",
36
30
  poolside: "https://poolside.ai/favicon/favicon.svg",
31
+ // AWS owns Kiro; Wikimedia hosts its vector, as AWS serves only a 16x16 ICO that cannot grow into a tile.
32
+ amazonaws: "https://upload.wikimedia.org/wikipedia/commons/5/5c/AWS_Simple_Icons_AWS_Cloud.svg",
37
33
  cerebras:
38
34
  "https://cdn.sanity.io/images/e4qjo92p/production/e7a55ae5ab7e2c4fdfd4e66a51f628d1f2f44207-967x967.png?w=256&h=256&fit=max",
39
35
  inclusionai: "https://cdn-avatars.huggingface.co/v1/production/uploads/662e1f9da266499277937d33/fyKuazRifqiaIO34xrhhm.jpeg",
@@ -48,14 +44,18 @@ export const REMOTE_MARKS: Readonly<Record<string, string>> = {
48
44
  magpie: "https://usemagpie.ai/favicon.png",
49
45
  gmicloud: "https://gmicloud.ai/favicon.ico",
50
46
  charmhyper: "https://github.com/charmbracelet.png?size=80",
47
+ // WorkBuddy's own favicon, a hashed asset on the Tencent CDN its page serves.
48
+ workbuddy:
49
+ "https://download.codebuddy.ai/web/workbuddy/913b2390aae396c66b937bd9143ae5259306d7d7/assets/logo.svg",
50
+ // Trae's own favicon: a dark tile carrying its green mark, so the tile matches the artwork.
51
+ trae: "https://lf16-web-neutral.traecdn.ai/obj/trae-ai-static/trae_website/favicon.png",
52
+ // Kiro's own icon, a purple tile with the mark drawn inside it.
53
+ kiro: "https://kiro.dev/icon.svg",
54
+ // Meituan's own icon: its yellow tile with the logotype.
55
+ meituan: "https://s3plus.meituan.net/v1/mss_e2821d7f0cfe4ac1bf9202ecf9590e67/cdn-prod/file:1040877d/favicon-mt.ico",
51
56
  };
52
57
 
53
- /**
54
- * Tile fill, not text. A mark whose tile carries its brand color reads as the brand, and a mark
55
- * whose logo already fills its own box never shows the fill. Values come from the brand (the
56
- * service's own page or its own asset), never from a guess; a slug absent here keeps the plain
57
- * panel and the theme ink.
58
- */
58
+ /** Tile fill per slug from the brand's own page, never a guess; an absent slug keeps the plain panel. */
59
59
  export const MARK_BG: Readonly<Record<string, string>> = {
60
60
  // Simple Icons marks: the vendor's brand color, taken from its own brand page.
61
61
  anthropic: "#d97757",
@@ -80,8 +80,7 @@ export const MARK_BG: Readonly<Record<string, string>> = {
80
80
  instagram: "#e4405f",
81
81
  reddit: "#ff4500",
82
82
  linkedin: "#0a66c2",
83
- // Owner-hosted marks: the tile matches the asset's own square, and a slug whose artwork already
84
- // carries its own color takes a white tile so the art reads.
83
+ // Owner-hosted marks match the asset's own square; art that already carries color takes a white tile.
85
84
  groq: "#f43e01",
86
85
  poolside: "#4137ff",
87
86
  cerebras: "#ffffff",
@@ -90,18 +89,22 @@ export const MARK_BG: Readonly<Record<string, string>> = {
90
89
  stepfun: "#ffffff",
91
90
  kilo: "#f7f576",
92
91
  opencode: "#131010",
92
+ // The AWS cloud mark draws its own orange and its own white, so the tile stays plain white.
93
+ amazonaws: "#ffffff",
93
94
  // The mark's own artwork is white, so the tile is white and the glyph is black.
94
95
  openai: "#ffffff",
95
96
  commandcode: "#ffffff",
96
97
  gmicloud: "#ffffff",
97
98
  magpie: "#0b0b0c",
99
+ // WorkBuddy's mark is a full-bleed teal tile carrying its own white art.
100
+ workbuddy: "#01c886",
101
+ // The mark is a full-bleed tile, so the tile fill repeats the artwork's own background.
102
+ trae: "#000000",
103
+ kiro: "#9046ff",
104
+ meituan: "#ffd100",
98
105
  };
99
106
 
100
- /**
101
- * Glyph scale per slug, applied to the glyph box the tile clips. Simple Icons art fills its 24 box
102
- * by very different amounts, so the measured ink box decides the scale: a logo that runs to its
103
- * box edge shrinks and one with an invisible margin grows. One is the default.
104
- */
107
+ /** Glyph scale per slug over the tile clip; the measured ink box decides, and one is the default. */
105
108
  export const MARK_SCALE: Readonly<Record<string, number>> = {
106
109
  kimi: 0.72,
107
110
  zdotai: 0.72,
@@ -119,21 +122,12 @@ export function luminance(hex: string): number {
119
122
  return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
120
123
  }
121
124
 
122
- /**
123
- * The glyph color that stays legible on a tile fill. The cut sits at 0.35, which keeps white ink
124
- * on a brand color: Meta blue, DeepSeek blue, Xiaomi orange, Mistral orange, and Kimi blue all
125
- * carry a white logo, which is how their own brand sheets print them. Only a light neutral takes
126
- * black: Kilo yellow, StepFun teal, and Lightbend orange.
127
- */
125
+ /** The ink legible on a tile fill: luminance above 0.35 takes black, else white. */
128
126
  export function contrastInk(hex: string): string {
129
127
  return luminance(hex) > 0.35 ? "#000000" : "#ffffff";
130
128
  }
131
129
 
132
- /**
133
- * Marks that carry their own color or their own background. Everything else is opaque black, so
134
- * it draws as an alpha mask filled with the chosen ink instead of an image. A slug whose asset is
135
- * a filled tile must draw as an image, or the mask paints one flat block and the logo vanishes.
136
- */
130
+ /** Marks carrying their own color or background; a filled-tile asset must draw as an image, not a mask. */
137
131
  export const COLORED_MARKS: ReadonlySet<string> = new Set([
138
132
  "cerebras",
139
133
  "inclusionai",
@@ -144,12 +138,18 @@ export const COLORED_MARKS: ReadonlySet<string> = new Set([
144
138
  "magpie",
145
139
  "charmhyper",
146
140
  "stepfun",
147
- // The ICO and the two inline PNGs are dark tiles with light art drawn inside them. The Groq
148
- // favicon is one flat orange, so it draws as a mask instead: a masked glyph on a Groq-orange
149
- // tile is a logo, and the same art as an image on the same orange tile is a blank tile.
141
+ // The ICO and inline PNGs are dark tiles with light art; flat-orange Groq draws as a mask instead.
150
142
  "gmicloud",
151
143
  "stealth",
152
144
  "cognition",
145
+ // A full-bleed teal tile with its own white art, so a mask would flatten both into one ink.
146
+ "workbuddy",
147
+ // Each is a full-bleed brand tile with its own ink, so a mask would flatten both into one color.
148
+ "trae",
149
+ "kiro",
150
+ "meituan",
151
+ // Carries its own orange and its own white, so a mask would flatten both into one ink.
152
+ "amazonaws",
153
153
  ]);
154
154
 
155
155
  /** The URL a mark comes from, or null when the name has no hosted mark. */
@@ -44,7 +44,7 @@ interface RegistryEntry {
44
44
  source: 'registry' | 'borrowed';
45
45
  }
46
46
 
47
- // Registry values verbatim from EcoLogits' model_repository; borrows cite the public param disclosure they stand in for (MoE active/total).
47
+ // Verbatim from EcoLogits' model_repository; borrows cite the param disclosure they replace (MoE active/total).
48
48
  const TABLE: RegistryEntry[] = [
49
49
  { match: 'opus', active: 625.0, total: 2000, provider: 'anthropic', source: 'registry' },
50
50
  { match: 'sonnet', active: 137.5, total: 440, provider: 'anthropic', source: 'registry' },
@@ -11,7 +11,7 @@ export function tersioSettingsFile(): string {
11
11
  return path.join(tersioHome(), 'settings.json');
12
12
  }
13
13
 
14
- // Older installs kept these as OMP plugin settings; still read so switching the store does not reset a user's defaults.
14
+ // Older installs kept these as OMP plugin settings; still read so switching stores does not reset a user's defaults.
15
15
  function legacyOmpSettings(): Record<string, unknown> | null {
16
16
  const configHome = process.env.XDG_CONFIG_HOME || path.join(homeDir(), '.config');
17
17
  for (const p of [
@@ -39,7 +39,7 @@ function readSettingsFile(): Record<string, unknown> | null {
39
39
  }
40
40
  }
41
41
 
42
- // The stored settings; empty when the file is missing, corrupt, or empty.
42
+ // The stored settings; {} when the file is missing or corrupt.
43
43
  export function readPluginSettings(): Record<string, unknown> {
44
44
  return readSettingsFile() ?? legacyOmpSettings() ?? {};
45
45
  }
@@ -47,6 +47,8 @@ export function readPluginSettings(): Record<string, unknown> {
47
47
  const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
48
48
  const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'megacave', 'megacave-lite', 'megacave-full', 'megacave-ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
49
49
  const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra']);
50
+ // Opt-in only: no combo preset writes this, so it never reaches a user who did not ask for it.
51
+ const PSTACK_MODES = new Set(['off', 'on']);
50
52
 
51
53
  function readStringDefault(key: string, valid: Set<string>): string {
52
54
  const raw = readPluginSettings()[key];
@@ -70,15 +72,19 @@ export function readPonytailDefault(): string {
70
72
  return readStringDefault('ponytailDefault', PONYTAIL_MODES);
71
73
  }
72
74
 
75
+ export function readPstackDefault(): string {
76
+ return readStringDefault('pstackDefault', PSTACK_MODES);
77
+ }
78
+
73
79
  export function isComboSetupComplete(): boolean {
74
80
  return readPluginSettings().comboSetupComplete === true;
75
81
  }
76
82
 
77
83
  const COMBO_MODE_DEFAULTS = {
78
- off: { cavemanDefault: 'off', rtkDefault: false, ponytailDefault: 'off' },
79
- medium: { cavemanDefault: 'lite', rtkDefault: true, ponytailDefault: 'lite' },
80
- balanced: { cavemanDefault: 'full', rtkDefault: true, ponytailDefault: 'full' },
81
- max: { cavemanDefault: 'ultra', rtkDefault: true, ponytailDefault: 'ultra' },
84
+ off: { cavemanDefault: 'off', rtkDefault: false, ponytailDefault: 'off', pstackDefault: 'off' },
85
+ medium: { cavemanDefault: 'lite', rtkDefault: true, ponytailDefault: 'lite', pstackDefault: 'on' },
86
+ balanced: { cavemanDefault: 'full', rtkDefault: true, ponytailDefault: 'full', pstackDefault: 'on' },
87
+ max: { cavemanDefault: 'ultra', rtkDefault: true, ponytailDefault: 'ultra', pstackDefault: 'on' },
82
88
  } as const;
83
89
 
84
90
  export function saveComboSetup(level: string): boolean {
@@ -1,4 +1,4 @@
1
- // extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id → provider-prefix strip → default, reported with known:false when unpriced.
1
+ // pricing.ts — model pricing from LiteLLM. Exact id → prefix strip → default, known:false if unpriced.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { tersioDataPath } from './utils.ts';
@@ -13,18 +13,10 @@ export interface ModelPrice {
13
13
  // Sonnet-class default for unpriced models; always paired with known:false.
14
14
  export const DEFAULT_PRICE: ModelPrice = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
15
15
 
16
- /**
17
- * How a price was found, strongest first. `tail` and `default` are estimates.
18
- *
19
- * - `measured`: the host reported it. The only tier that can be zero without being a guess.
20
- * - `override`: the operator's `prices.local.json`, exact for the gateway it names.
21
- * - `exact`: the feed holds the id verbatim, or case-folded. `alias`: via `MODEL_ALIASES`.
22
- * - `tail`: the feed holds the bare name under some provider, which need not be the one billed.
23
- * - `default`: no match. `DEFAULT_PRICE`, an estimate by definition.
24
- */
16
+ /** How a price was found, strongest first; `tail` and `default` are estimates. */
25
17
  export type PriceTier = 'measured' | 'override' | 'exact' | 'alias' | 'tail' | 'default';
26
18
 
27
- /** Tiers firm enough to call a price. `tail` and `default` are estimates, so they are not. */
19
+ /** Tiers firm enough to call a price; `tail` and `default` are estimates. */
28
20
  export const PRICE_TIERS_FIRM: readonly PriceTier[] = ['measured', 'override', 'exact', 'alias'];
29
21
 
30
22
  export interface PriceHit {
@@ -35,7 +27,7 @@ export interface PriceHit {
35
27
  }
36
28
 
37
29
  const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
38
- // Readers use the file even when stale, and only one refresh runs per process; `tersio update` still forces a fresh fetch.
30
+ // Readers use the file even when stale; one refresh per process. `tersio update` still forces a fresh fetch.
39
31
  const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
40
32
  let refreshInflight: Promise<boolean> | null = null;
41
33
 
@@ -45,11 +37,7 @@ export function pricesCachePath(): string {
45
37
  return tersioDataPath('prices.json', 'tersio-prices.json');
46
38
  }
47
39
 
48
- /**
49
- * The operator's own prices, merged over the feed and keyed exactly as the transcript spells a
50
- * model. A `*` prefix matches any gateway suffix. This is where a model the feed keys under a
51
- * different provider gets a real price.
52
- */
40
+ /** Operator prices keyed as the transcript spells a model; a `*` prefix matches any gateway suffix. */
53
41
  export function pricesOverridePath(): string {
54
42
  const override = process.env.TERSIO_PRICES_OVERRIDE_FILE;
55
43
  if (override) return override;
@@ -79,7 +67,6 @@ function loadOverrides(stamp = overrideStamp()): { exact: Map<string, ModelPrice
79
67
  for (const [id, row] of Object.entries(parsed ?? {})) {
80
68
  const price = asPrice(row);
81
69
  if (!price) continue;
82
- // A `*` prefix matches any gateway prefix, so one entry covers every spelling of a model.
83
70
  if (id.startsWith('*')) tails.push([id.slice(1).toLowerCase(), price]);
84
71
  else exact.set(id, price);
85
72
  }
@@ -94,7 +81,7 @@ export function pricesUrl(): string {
94
81
 
95
82
  export interface LivePrices {
96
83
  fetchedAt: number;
97
- // Compact tuples [input, output, cacheRead, cacheWrite] per LiteLLM id — the full feed caches to ~1MB instead of multi-MB objects.
84
+ // Tuples [input, output, cacheRead, cacheWrite] per LiteLLM id — the full feed caches to ~1MB, not multi-MB objects.
98
85
  exact: Record<string, [number, number, number, number]>;
99
86
  deprecated?: string[];
100
87
  }
@@ -192,7 +179,7 @@ export function priceFor(model: string, live?: LivePrices | null): PriceHit {
192
179
  return resolvePrice(model, table, overrideStamp());
193
180
  }
194
181
 
195
- // Recent rows repeat a handful of models; the memo turns thousands of lookups into one per model. Cleared whenever the table identity changes.
182
+ // Recent rows repeat a few models; the memo turns thousands of lookups into one per model. Cleared on table change.
196
183
  let memoTable: LivePrices | null | undefined;
197
184
  let memoStamp = 0;
198
185
  let memo = new Map<string, PriceHit>();
@@ -227,21 +214,18 @@ function resolvePrice(model: string, table: LivePrices | null, stamp = overrideS
227
214
  }
228
215
  }
229
216
 
230
- // An alias goes through the same chain as any other id, so it still resolves when the feed keys
231
- // it under a provider prefix.
217
+ // An alias runs the same chain, so it still resolves when the feed keys it under a provider prefix.
232
218
  const alias = MODEL_ALIASES.find((a) => a.id === model.toLowerCase());
233
219
  const id = alias?.feed ?? model;
234
220
  if (table) {
235
221
  const entries = priceEntries(table);
236
- // 1. The id verbatim, then case-folded. Nothing is being guessed here.
222
+ // The id verbatim, then case-folded; nothing guessed.
237
223
  const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === id.toLowerCase());
238
224
  if (hit) return { price: hit[1], known: true, live: true, tier: alias ? 'alias' : 'exact' };
239
225
 
240
226
  const lower = id.toLowerCase();
241
227
  const tail = lower.split('/').pop() ?? lower;
242
- // The bare model name under any provider, which need not be the provider that billed the row.
243
- // Reported as tier `tail`, so a caller keeps it out of a firm total; `prices.local.json` fixes
244
- // a specific case.
228
+ // Bare name under any provider (need not be the billing one); tier `tail` keeps it out of a firm total.
245
229
  const bare = entries.find(([k]) => {
246
230
  const lk = k.toLowerCase();
247
231
  return lk === tail || lk.endsWith(`/${tail}`);
@@ -0,0 +1,83 @@
1
+ // Eligible command heads, taken from rtk's own contract rather than a hardcoded guess.
2
+ // `rtk rewrite <probe>` prints a rewrite and exits 0 or 3 when rtk handles the command,
3
+ // and exits 1 when no equivalent exists. Each probe below is a representative workload
4
+ // command, because subcommand-scoped tools such as `gh` rewrite `gh pr list` but not `gh`.
5
+ import { execFileSync } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { normalizeRtkVersion, tersioDataPath } from './utils.ts';
9
+
10
+ /** Verified against rtk 1.0.1; the drift test fails when rtk's set moves. */
11
+ export const FALLBACK_ELIGIBLE_HEADS: readonly string[] = [
12
+ 'rtk', 'git', 'gh', 'glab', 'docker', 'kubectl', 'psql', 'aws', 'bunx', 'npx',
13
+ 'pytest', 'mypy', 'cat', 'grep', 'rg', 'find', 'tree', 'diff', 'ls', 'ast-grep',
14
+ 'wget', 'prettier', 'playwright', 'prisma', 'jest', 'vitest', 'ctest', 'lint',
15
+ 'wc', 'tsc', 'curl', 'cargo', 'npm', 'pnpm', 'bun', 'go', 'ruff', 'next',
16
+ 'dotnet', 'oc', 'jq', 'make',
17
+ ];
18
+
19
+ const PROBES: Record<string, string> = {
20
+ git: 'git log', gh: 'gh pr list', glab: 'glab mr list', docker: 'docker ps',
21
+ kubectl: 'kubectl get pods', psql: 'psql --version', aws: 'aws --version',
22
+ bunx: 'bunx --version', npx: 'npx --version', pytest: 'pytest --version',
23
+ mypy: 'mypy --version', cat: 'cat README.md', grep: 'grep rtk README.md',
24
+ rg: 'rg rtk README.md', find: 'find . -name x', tree: 'tree .', diff: 'diff a b',
25
+ ls: 'ls -la', 'ast-grep': 'ast-grep --version', wget: 'wget --version',
26
+ prettier: 'prettier --version', playwright: 'playwright --version', prisma: 'prisma --version',
27
+ jest: 'jest --version', vitest: 'vitest --version', ctest: 'ctest --version',
28
+ lint: 'lint .', wc: 'wc -l x', tsc: 'tsc --version', curl: 'curl --version',
29
+ cargo: 'cargo test', npm: 'npm run build', pnpm: 'pnpm run build', bun: 'bun run test',
30
+ go: 'go test ./...', ruff: 'ruff check .', next: 'next build', dotnet: 'dotnet build',
31
+ oc: 'oc get pods', jq: 'jq . x', make: 'make',
32
+ };
33
+
34
+ function probe(bin: string, command: string): boolean {
35
+ try {
36
+ const out = execFileSync(bin, ['rewrite', command], { encoding: 'utf8', timeout: 5000, windowsHide: true });
37
+ return out.trim().length > 0;
38
+ } catch (e) {
39
+ // Non-zero exit throws; exit 3 still prints a rewrite, exits 1 and 2 do not.
40
+ const err = e as { status?: number; stdout?: string | Buffer };
41
+ if (err.status !== 0 && err.status !== 3) return false;
42
+ return String(err.stdout ?? '').trim().length > 0;
43
+ }
44
+ }
45
+
46
+ /** One set of heads for this rtk build, read straight from its rewrite contract. */
47
+ export function deriveEligibleHeads(bin: string): Set<string> {
48
+ const heads = new Set<string>(['rtk']);
49
+ for (const [head, command] of Object.entries(PROBES)) {
50
+ if (probe(bin, command)) heads.add(head);
51
+ }
52
+ return heads;
53
+ }
54
+
55
+ function rtkVersion(bin: string): string {
56
+ try {
57
+ return normalizeRtkVersion(execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }));
58
+ } catch {
59
+ return '';
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Heads rtk rewrites, cached per binary version so an upgrade re-derives once.
65
+ * Without a binary, or on any read failure, the verified fallback stands.
66
+ */
67
+ export function eligibleHeads(bin: string | null): ReadonlySet<string> {
68
+ if (!bin) return new Set(FALLBACK_ELIGIBLE_HEADS);
69
+ const version = rtkVersion(bin);
70
+ const file = tersioDataPath('rtk-eligible.json', 'rtk-eligible.json');
71
+ try {
72
+ const cached = JSON.parse(fs.readFileSync(file, 'utf8')) as { version?: unknown; heads?: unknown };
73
+ if (cached.version === version && Array.isArray(cached.heads) && cached.heads.every((h) => typeof h === 'string')) {
74
+ return new Set(cached.heads as string[]);
75
+ }
76
+ } catch { /* derive below */ }
77
+ const heads = deriveEligibleHeads(bin);
78
+ try {
79
+ fs.mkdirSync(path.dirname(file), { recursive: true });
80
+ fs.writeFileSync(file, `${JSON.stringify({ version, heads: [...heads] })}\n`, 'utf8');
81
+ } catch { /* the cache is best-effort */ }
82
+ return heads;
83
+ }
@@ -1,8 +1,8 @@
1
- // Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
2
- import { execFileSync } from 'node:child_process';
1
+ // Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK_GUIDE.md.
3
2
  import fs from 'node:fs';
4
3
  import os from 'node:os';
5
4
  import path from 'node:path';
5
+ import { sqliteRead } from './sqlite.ts';
6
6
 
7
7
  export interface RtkCommandRow {
8
8
  command: string;
@@ -23,7 +23,7 @@ export interface RtkGain {
23
23
 
24
24
  const EMPTY: RtkGain = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
25
25
 
26
- // The table shows one clipped cell, so bound the command before it crosses the sqlite3 CLI and lands in every /data.json poll.
26
+ // The table shows one clipped cell, so bound the command before it reaches /data.json.
27
27
  const MAX_CMD_CHARS = 200;
28
28
 
29
29
  // A multi-line stored command would derail every row after it; flatten on read.
@@ -40,12 +40,7 @@ export function rtkDbPath(): string {
40
40
  }
41
41
 
42
42
  function query(db: string, sql: string): string[][] {
43
- const out = execFileSync('sqlite3', ['-separator', '\t', db, sql], {
44
- encoding: 'utf8',
45
- timeout: 5000,
46
- stdio: ['ignore', 'pipe', 'ignore'],
47
- });
48
- return out
43
+ return sqliteRead(db, sql)
49
44
  .split('\n')
50
45
  .filter((line) => line.trim())
51
46
  .map((line) => line.split('\t'));
@@ -54,7 +49,7 @@ function query(db: string, sql: string): string[][] {
54
49
  export function readRtkGain(limit = 10, cutoffMs?: number): RtkGain {
55
50
  try {
56
51
  if (!fs.existsSync(rtkDbPath())) return EMPTY;
57
- // View-level cutoff only: rtk's own rows stay untouched. Timestamps that do not parse land on 0 and drop out of a filtered view.
52
+ // View-level cutoff only: rtk's rows stay untouched. Unparseable timestamps land on 0 and drop from filtered views.
58
53
  const where = cutoffMs && cutoffMs > 0
59
54
  ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
60
55
  : '';
@@ -3,32 +3,35 @@ import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry } from './types
3
3
 
4
4
  const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
5
5
 
6
- // A verbatim sentence from omp's subagent prompt, so it can drift; read from the installed omp binary on 2026-10-01. pi has no built-in subagent prompt.
6
+ // A verbatim sentence from omp's subagent prompt, so it can drift; read from the installed omp binary on 2026-10-01.
7
7
  export const OMP_SUBAGENT_MARKER = 'Worker agent: delegated tasks.';
8
8
 
9
+ // Every level above off turns pstack on with the other three; off is the one level without it.
9
10
  export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze({
10
- off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
11
- medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite' }),
12
- balanced: Object.freeze({ level: 'balanced', caveman: 'full', rtk: 'on', ponytail: 'full' }),
13
- max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra' }),
11
+ off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off', pstack: 'off' }),
12
+ medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite', pstack: 'on' }),
13
+ balanced: Object.freeze({ level: 'balanced', caveman: 'full', rtk: 'on', ponytail: 'full', pstack: 'on' }),
14
+ max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra', pstack: 'on' }),
14
15
  });
15
16
 
16
17
  const MODE_VALUES: Record<string, Set<string>> = {
17
18
  caveman: new Set(['off', 'lite', 'full', 'ultra', 'megacave', 'megacave-lite', 'megacave-full', 'megacave-ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']),
18
19
  rtk: new Set(['off', 'on']),
19
20
  ponytail: new Set(['off', 'lite', 'full', 'ultra']),
21
+ pstack: new Set(['off', 'on']),
20
22
  };
21
23
 
22
24
  // Upstream renamed wenyan to megacave and kept the old spelling as an alias.
23
25
  const CAVEMAN_LEGACY_PREFIX = /^wenyan/;
24
26
 
25
- type ModeName = 'caveman' | 'rtk' | 'ponytail';
27
+ type ModeName = 'caveman' | 'rtk' | 'ponytail' | 'pstack';
26
28
  type Modes = Record<ModeName, string>;
27
29
 
28
30
  const MODE_ENTRY_TYPES: Record<string, ModeName> = {
29
31
  'caveman-mode': 'caveman',
30
32
  'rtk-mode': 'rtk',
31
33
  'ponytail-mode': 'ponytail',
34
+ 'pstack-mode': 'pstack',
32
35
  };
33
36
 
34
37
  /** True when the branch carries Tersio's own mode state; a subagent starts without it. */
@@ -54,7 +57,7 @@ export function normalizeMode(name: ModeName, value: unknown): string | null {
54
57
  export function deriveLevel(modes: Modes | null | undefined): ComboLevel {
55
58
  for (const level of Object.keys(COMBO_LEVELS) as ComboLevel[]) {
56
59
  const preset = COMBO_LEVELS[level];
57
- if (preset.caveman === modes?.caveman && preset.rtk === modes?.rtk && preset.ponytail === modes?.ponytail) return level;
60
+ if (preset.caveman === modes?.caveman && preset.rtk === modes?.rtk && preset.ponytail === modes?.ponytail && preset.pstack === modes?.pstack) return level;
58
61
  }
59
62
  return 'custom';
60
63
  }
@@ -72,6 +75,7 @@ function normalizedState(modes: Partial<Modes> | null | undefined, level: ComboL
72
75
  caveman: normalizeMode('caveman', modes?.caveman) || 'off',
73
76
  rtk: normalizeMode('rtk', modes?.rtk) || 'off',
74
77
  ponytail: normalizeMode('ponytail', modes?.ponytail) || 'off',
78
+ pstack: normalizeMode('pstack', modes?.pstack) || 'off',
75
79
  };
76
80
  return Object.freeze({ level: isKnownLevel(level) ? level : deriveLevel(state), ...state });
77
81
  }
@@ -122,7 +126,7 @@ export function normalizeComboLevel(value: unknown): ComboLevel | null {
122
126
  }
123
127
 
124
128
 
125
- // Last-wins scan for a custom session entry; skips entries whose value fails to parse so a corrupt write never shadows an older valid one.
129
+ // Last-wins scan for a custom session entry; skips unparseable values so a corrupt write never shadows a valid one.
126
130
  export function lastCustomValue<T>(entries: SessionEntry[] | null | undefined, customType: string, pick: (data: SessionEntry['data']) => T | null | undefined): T | null {
127
131
  if (!Array.isArray(entries)) return null;
128
132
  for (let i = entries.length - 1; i >= 0; i -= 1) {
@@ -156,15 +160,16 @@ export function setSharedComboMode(name: ModeName, value: unknown): Readonly<Com
156
160
  const mode = normalizeMode(name, value);
157
161
  if (!mode) return getSharedComboState();
158
162
  const modes = { ...getSharedComboState(), [name]: mode } as Modes;
159
- // Derive the level from the final triplet so a redundant same-value write cannot strand a matching preset at 'custom' and drop the combo bar.
163
+ // Derive the level from the final quartet, so a redundant same-value write cannot strand a preset at 'custom'.
160
164
  return publish(normalizedState(modes));
161
165
  }
162
166
 
163
- export function setSharedComboModes(modes: { caveman?: unknown; rtk?: unknown; ponytail?: unknown }): Readonly<ComboState> {
167
+ export function setSharedComboModes(modes: { caveman?: unknown; rtk?: unknown; ponytail?: unknown; pstack?: unknown }): Readonly<ComboState> {
164
168
  return publish(normalizedState({
165
169
  caveman: normalizeMode('caveman', modes.caveman) || 'off',
166
170
  rtk: normalizeMode('rtk', modes.rtk) || 'off',
167
171
  ponytail: normalizeMode('ponytail', modes.ponytail) || 'off',
172
+ pstack: normalizeMode('pstack', modes.pstack) || 'off',
168
173
  }));
169
174
  }
170
175
 
@@ -185,13 +190,13 @@ export function reconcileSharedComboEntries(entries: SessionEntry[] | null | und
185
190
  if (mode) modes[name] = mode;
186
191
  }
187
192
  }
188
- // The level reflects the final triplet, never the write order, so a preset entry followed by the same values reconciles back to the preset.
193
+ // The level reflects the final quartet, not write order, so a preset followed by the same values reconciles back.
189
194
  return publish(normalizedState(modes));
190
195
  }
191
196
 
192
197
  // One-line session-wide mode summary for change confirmations.
193
- export function activeModesSummary(state: { caveman: string; rtk: string; ponytail: string }): string {
194
- return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
198
+ export function activeModesSummary(state: { caveman: string; rtk: string; ponytail: string; pstack: string }): string {
199
+ return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}, pstack=${state.pstack.toUpperCase()}`;
195
200
  }
196
201
 
197
202
  // Keyed registration drops stale closures; hosts offer no teardown hook.