@causyne/agent 0.0.0-stage → 1.10.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 (274) hide show
  1. package/LICENSE +102 -0
  2. package/README.md +31 -2
  3. package/SECURITY.md +24 -0
  4. package/bin/causyne.js +13 -0
  5. package/docs/ARCHITECTURE.md +63 -0
  6. package/docs/PROVENANCE.md +29 -0
  7. package/docs/SECURITY_REVIEW.md +89 -0
  8. package/docs/THIRD_PARTY_NOTICES.md +49 -0
  9. package/native-skills/ai-engineering/context-window-management/SKILL.md +43 -0
  10. package/native-skills/ai-engineering/llm-evaluation/SKILL.md +46 -0
  11. package/native-skills/ai-engineering/model-cost-and-latency/SKILL.md +46 -0
  12. package/native-skills/ai-engineering/prompt-construction/SKILL.md +42 -0
  13. package/native-skills/ai-engineering/retrieval-augmented-generation/SKILL.md +42 -0
  14. package/native-skills/ai-engineering/structured-output-and-schemas/SKILL.md +45 -0
  15. package/native-skills/ai-engineering/tool-calling-agents/SKILL.md +43 -0
  16. package/native-skills/android/app-performance-profiling/SKILL.md +42 -0
  17. package/native-skills/android/background-work-scheduling/SKILL.md +47 -0
  18. package/native-skills/android/compose-state-and-recomposition/SKILL.md +43 -0
  19. package/native-skills/android/gradle-build-configuration/SKILL.md +41 -0
  20. package/native-skills/android/lifecycle-and-leaks/SKILL.md +46 -0
  21. package/native-skills/android/play-store-release/SKILL.md +43 -0
  22. package/native-skills/android/room-and-persistence/SKILL.md +40 -0
  23. package/native-skills/backend/authentication-and-sessions/SKILL.md +45 -0
  24. package/native-skills/backend/background-jobs-and-queues/SKILL.md +42 -0
  25. package/native-skills/backend/caching-strategy/SKILL.md +44 -0
  26. package/native-skills/backend/graceful-shutdown/SKILL.md +42 -0
  27. package/native-skills/backend/rate-limiting-and-quotas/SKILL.md +40 -0
  28. package/native-skills/backend/rest-api-design/SKILL.md +40 -0
  29. package/native-skills/backend/webhook-delivery-and-receipt/SKILL.md +43 -0
  30. package/native-skills/cloud/cost-optimisation/SKILL.md +46 -0
  31. package/native-skills/cloud/iam-least-privilege/SKILL.md +43 -0
  32. package/native-skills/cloud/infrastructure-as-code/SKILL.md +43 -0
  33. package/native-skills/cloud/multi-region-and-failover/SKILL.md +43 -0
  34. package/native-skills/cloud/serverless-patterns/SKILL.md +47 -0
  35. package/native-skills/data/data-quality-monitoring/SKILL.md +51 -0
  36. package/native-skills/data/etl-pipeline-reliability/SKILL.md +43 -0
  37. package/native-skills/data/exploratory-analysis/SKILL.md +43 -0
  38. package/native-skills/data/metric-definition/SKILL.md +42 -0
  39. package/native-skills/data/notebook-to-production/SKILL.md +43 -0
  40. package/native-skills/database/backup-and-recovery/SKILL.md +43 -0
  41. package/native-skills/database/connection-pooling/SKILL.md +42 -0
  42. package/native-skills/database/data-integrity-and-constraints/SKILL.md +42 -0
  43. package/native-skills/database/query-optimisation/SKILL.md +38 -0
  44. package/native-skills/database/safe-migrations/SKILL.md +47 -0
  45. package/native-skills/database/schema-design/SKILL.md +38 -0
  46. package/native-skills/database/transactions-and-isolation/SKILL.md +41 -0
  47. package/native-skills/design/colour-system-construction/SKILL.md +38 -0
  48. package/native-skills/design/design-direction-brief/SKILL.md +41 -0
  49. package/native-skills/design/motion-and-transitions/SKILL.md +38 -0
  50. package/native-skills/design/responsive-strategy/SKILL.md +39 -0
  51. package/native-skills/design/spacing-and-rhythm/SKILL.md +38 -0
  52. package/native-skills/design/type-scale-and-hierarchy/SKILL.md +39 -0
  53. package/native-skills/design/visual-qa-loop/SKILL.md +42 -0
  54. package/native-skills/devops/docker-multi-stage-builds/SKILL.md +60 -0
  55. package/native-skills/devops/github-actions-ci/SKILL.md +60 -0
  56. package/native-skills/devops/incident-triage/SKILL.md +38 -0
  57. package/native-skills/devops/kubernetes-deployment-hardening/SKILL.md +64 -0
  58. package/native-skills/devops/observability-baseline/SKILL.md +38 -0
  59. package/native-skills/devops/secret-management/SKILL.md +40 -0
  60. package/native-skills/devops/terraform-state-and-modules/SKILL.md +36 -0
  61. package/native-skills/devops/zero-downtime-deploys/SKILL.md +40 -0
  62. package/native-skills/git/bisect-regression-hunt/SKILL.md +43 -0
  63. package/native-skills/git/branch-strategy/SKILL.md +39 -0
  64. package/native-skills/git/commit-hygiene/SKILL.md +41 -0
  65. package/native-skills/git/history-recovery/SKILL.md +44 -0
  66. package/native-skills/git/large-repository-surgery/SKILL.md +46 -0
  67. package/native-skills/git/merge-conflict-resolution/SKILL.md +42 -0
  68. package/native-skills/media/cinematic-3d-shot/SKILL.md +104 -0
  69. package/native-skills/mobile/app-startup-optimisation/SKILL.md +41 -0
  70. package/native-skills/mobile/cross-platform-tradeoffs/SKILL.md +42 -0
  71. package/native-skills/mobile/deep-linking/SKILL.md +39 -0
  72. package/native-skills/mobile/mobile-storage-security/SKILL.md +45 -0
  73. package/native-skills/mobile/offline-first-sync/SKILL.md +42 -0
  74. package/native-skills/mobile/push-notifications/SKILL.md +38 -0
  75. package/native-skills/performance/algorithmic-complexity/SKILL.md +43 -0
  76. package/native-skills/performance/concurrency-and-parallelism/SKILL.md +42 -0
  77. package/native-skills/performance/load-testing/SKILL.md +42 -0
  78. package/native-skills/performance/memory-and-leak-hunting/SKILL.md +41 -0
  79. package/native-skills/performance/network-and-payload-efficiency/SKILL.md +42 -0
  80. package/native-skills/performance/profiling-methodology/SKILL.md +46 -0
  81. package/native-skills/python/async-correctness/SKILL.md +40 -0
  82. package/native-skills/python/cli-tool-construction/SKILL.md +44 -0
  83. package/native-skills/python/data-processing-pipelines/SKILL.md +38 -0
  84. package/native-skills/python/environment-isolation/SKILL.md +41 -0
  85. package/native-skills/python/packaging-and-distribution/SKILL.md +56 -0
  86. package/native-skills/python/pytest-suite-design/SKILL.md +39 -0
  87. package/native-skills/python/type-hints-and-static-checking/SKILL.md +42 -0
  88. package/native-skills/security/authorization-model/SKILL.md +42 -0
  89. package/native-skills/security/crypto-usage/SKILL.md +48 -0
  90. package/native-skills/security/dependency-supply-chain/SKILL.md +42 -0
  91. package/native-skills/security/input-validation-and-injection/SKILL.md +44 -0
  92. package/native-skills/security/secure-file-uploads/SKILL.md +46 -0
  93. package/native-skills/security/security-headers-and-csp/SKILL.md +44 -0
  94. package/native-skills/security/threat-modelling/SKILL.md +46 -0
  95. package/native-skills/testing/end-to-end-test-stability/SKILL.md +42 -0
  96. package/native-skills/testing/property-based-testing/SKILL.md +46 -0
  97. package/native-skills/testing/regression-test-from-a-bug/SKILL.md +41 -0
  98. package/native-skills/testing/snapshot-and-visual-regression/SKILL.md +42 -0
  99. package/native-skills/testing/test-doubles-and-mocking/SKILL.md +43 -0
  100. package/native-skills/testing/test-strategy-and-pyramid/SKILL.md +39 -0
  101. package/native-skills/testing/writing-good-assertions/SKILL.md +42 -0
  102. package/native-skills/web/accessible-components/SKILL.md +45 -0
  103. package/native-skills/web/api-client-resilience/SKILL.md +39 -0
  104. package/native-skills/web/bundle-size-reduction/SKILL.md +38 -0
  105. package/native-skills/web/core-web-vitals/SKILL.md +40 -0
  106. package/native-skills/web/css-layout-systems/SKILL.md +41 -0
  107. package/native-skills/web/form-validation-ux/SKILL.md +39 -0
  108. package/native-skills/web/react-state-architecture/SKILL.md +41 -0
  109. package/native-skills/web/ssr-hydration-correctness/SKILL.md +45 -0
  110. package/package.json +68 -4
  111. package/src/agentLoop.js +2458 -0
  112. package/src/appChannel.js +109 -0
  113. package/src/approvalGrants.js +66 -0
  114. package/src/auditLog.js +52 -0
  115. package/src/autoUpdate.js +12 -0
  116. package/src/capabilities.js +36 -0
  117. package/src/checkpoints.js +1199 -0
  118. package/src/cliMode.js +638 -0
  119. package/src/commands.js +805 -0
  120. package/src/compiledSkills.js +540 -0
  121. package/src/config.js +130 -0
  122. package/src/contextManager.js +498 -0
  123. package/src/cortex/autoCondenser.js +192 -0
  124. package/src/cortex/provisioner.js +27 -0
  125. package/src/cortex/vectorDb.js +447 -0
  126. package/src/designPalettes.js +99 -0
  127. package/src/devMode.js +6 -0
  128. package/src/embodimentSession.js +180 -0
  129. package/src/entrypoint.js +194 -0
  130. package/src/execSafe.js +21 -0
  131. package/src/gitSnapshot.js +378 -0
  132. package/src/mcp/jsonRpc.js +165 -0
  133. package/src/mcp/mcpClient.js +136 -0
  134. package/src/mcp/mcpRegistry.js +207 -0
  135. package/src/memory.js +62 -0
  136. package/src/memoryManager.js +124 -0
  137. package/src/modelCatalogue.js +132 -0
  138. package/src/modelIds.js +42 -0
  139. package/src/modelRouter.js +145 -0
  140. package/src/outputStore.js +246 -0
  141. package/src/perception.js +124 -0
  142. package/src/planCache.js +437 -0
  143. package/src/procSignal.js +32 -0
  144. package/src/providers/anthropic.js +408 -0
  145. package/src/providers/base.js +328 -0
  146. package/src/providers/dashscope-byok.js +86 -0
  147. package/src/providers/deepseek.js +80 -0
  148. package/src/providers/factory.js +37 -0
  149. package/src/providers/gemini.js +346 -0
  150. package/src/providers/moonshot.js +73 -0
  151. package/src/providers/muse.js +169 -0
  152. package/src/providers/ollama.js +104 -0
  153. package/src/providers/openai-compatible.js +84 -0
  154. package/src/providers/openai.js +20 -0
  155. package/src/providers/qwen-byok.js +76 -0
  156. package/src/providers/xai.js +73 -0
  157. package/src/provisioning/capabilityConsent.js +24 -0
  158. package/src/provisioning/capabilityProvisioner.js +271 -0
  159. package/src/provisioning/capabilityTools.js +29 -0
  160. package/src/reporting.js +373 -0
  161. package/src/sandbox.js +529 -0
  162. package/src/sessionStore.js +279 -0
  163. package/src/skillsManager.js +288 -0
  164. package/src/slopDetector.js +307 -0
  165. package/src/taskBuffer.js +45 -0
  166. package/src/taskShape.js +241 -0
  167. package/src/textDiff.js +150 -0
  168. package/src/tokenTracker.js +560 -0
  169. package/src/tool-scripts/ax_snapshot.applescript +39 -0
  170. package/src/tool-scripts/ax_snapshot.ps1 +10 -0
  171. package/src/tool-scripts/desktop_act.applescript +58 -0
  172. package/src/tool-scripts/resolve_automator.py +119 -0
  173. package/src/toolPacks.js +524 -0
  174. package/src/tools/archive.js +63 -0
  175. package/src/tools/astryxOracle.js +78 -0
  176. package/src/tools/axSnapshot.js +165 -0
  177. package/src/tools/blenderAutomator.js +349 -0
  178. package/src/tools/browserAutomator.js +252 -0
  179. package/src/tools/checkSystemSpecs.js +64 -0
  180. package/src/tools/clipboard.js +36 -0
  181. package/src/tools/codeSearchAst.js +101 -0
  182. package/src/tools/codeSearcher.js +230 -0
  183. package/src/tools/codeTools.js +262 -0
  184. package/src/tools/codebaseSearch.js +75 -0
  185. package/src/tools/createSkill.js +129 -0
  186. package/src/tools/deferredMcpSearch.js +246 -0
  187. package/src/tools/desktopAct.js +208 -0
  188. package/src/tools/desktopOpen.js +34 -0
  189. package/src/tools/dynamicLoader.js +44 -0
  190. package/src/tools/executeProceduralMedia.js +93 -0
  191. package/src/tools/fsDelete.js +51 -0
  192. package/src/tools/fsEdit.js +105 -0
  193. package/src/tools/fsFind.js +128 -0
  194. package/src/tools/fsList.js +77 -0
  195. package/src/tools/fsMove.js +57 -0
  196. package/src/tools/fsRead.js +87 -0
  197. package/src/tools/fsSearch.js +198 -0
  198. package/src/tools/fsWrite.js +41 -0
  199. package/src/tools/gameAiBehaviorTree.js +208 -0
  200. package/src/tools/gameAnimationRig.js +194 -0
  201. package/src/tools/gameAssetFetcher.js +372 -0
  202. package/src/tools/gameAssetHarvester.js +210 -0
  203. package/src/tools/gameAudioSynth.js +267 -0
  204. package/src/tools/gameBuilder.js +886 -0
  205. package/src/tools/gameCanvasAudit.js +101 -0
  206. package/src/tools/gameCinematicSequencer.js +151 -0
  207. package/src/tools/gameDataForge.js +124 -0
  208. package/src/tools/gameDialogueTreeGen.js +166 -0
  209. package/src/tools/gameEconomyBalancer.js +161 -0
  210. package/src/tools/gameEntitySpawner.js +207 -0
  211. package/src/tools/gameLoreForge.js +153 -0
  212. package/src/tools/gameQuestArcBuilder.js +152 -0
  213. package/src/tools/gameSceneBuilder.js +158 -0
  214. package/src/tools/gameShaderForge.js +181 -0
  215. package/src/tools/gameTilemapGenerator.js +164 -0
  216. package/src/tools/gameUiHudBuilder.js +292 -0
  217. package/src/tools/gameVfxEmitterGen.js +108 -0
  218. package/src/tools/gameWebRuntimeInspector.js +91 -0
  219. package/src/tools/generateMediaViaApi.js +358 -0
  220. package/src/tools/gitExec.js +54 -0
  221. package/src/tools/godotAutomator.js +437 -0
  222. package/src/tools/manageLocalAiDependencies.js +220 -0
  223. package/src/tools/memoryWrite.js +99 -0
  224. package/src/tools/multiEdit.js +107 -0
  225. package/src/tools/palmierAutomator.js +104 -0
  226. package/src/tools/pdfReader.js +112 -0
  227. package/src/tools/persistentShell.js +148 -0
  228. package/src/tools/printer.js +316 -0
  229. package/src/tools/procTerm.js +91 -0
  230. package/src/tools/processList.js +52 -0
  231. package/src/tools/readNativeSkill.js +39 -0
  232. package/src/tools/registry.js +277 -0
  233. package/src/tools/resolveAutomator.js +102 -0
  234. package/src/tools/screenshot.js +75 -0
  235. package/src/tools/subagentOrchestrator.js +305 -0
  236. package/src/tools/sysRun.js +125 -0
  237. package/src/tools/todoWrite.js +75 -0
  238. package/src/tools/toolDispatch.js +127 -0
  239. package/src/tools/toolForge.js +124 -0
  240. package/src/tools/updatePlan.js +88 -0
  241. package/src/tools/viewImage.js +82 -0
  242. package/src/tools/visionInspector.js +154 -0
  243. package/src/tools/webFetch.js +74 -0
  244. package/src/tools/webSearch.js +82 -0
  245. package/src/tui/ask.js +164 -0
  246. package/src/tui/banner.js +158 -0
  247. package/src/tui/block.js +152 -0
  248. package/src/tui/chatRenderer.js +953 -0
  249. package/src/tui/completion.js +424 -0
  250. package/src/tui/dialogs.js +606 -0
  251. package/src/tui/diff.js +296 -0
  252. package/src/tui/history.js +135 -0
  253. package/src/tui/home.js +217 -0
  254. package/src/tui/invocation.js +105 -0
  255. package/src/tui/keys.js +443 -0
  256. package/src/tui/lineEditor.js +256 -0
  257. package/src/tui/overlay.js +256 -0
  258. package/src/tui/prompt.js +1258 -0
  259. package/src/tui/selectList.js +335 -0
  260. package/src/tui/stage.js +330 -0
  261. package/src/tui/stageSurface.js +609 -0
  262. package/src/tui/statusLine.js +254 -0
  263. package/src/tui/text.js +345 -0
  264. package/src/tui/theme.js +393 -0
  265. package/src/tui/toolDisplay.js +176 -0
  266. package/src/tui/transcript.js +235 -0
  267. package/src/tui/viewport.js +337 -0
  268. package/src/uncertainty.js +271 -0
  269. package/src/userSkills.js +265 -0
  270. package/src/utils/cli_ui.js +0 -0
  271. package/src/utils/inquirer.js +12 -0
  272. package/src/utils/paths.js +37 -0
  273. package/src/version.js +38 -0
  274. package/src/vfeTracker.js +220 -0
@@ -0,0 +1,254 @@
1
+ // The line that tells the user what is happening to their money and their time.
2
+ //
3
+ // While a turn ran, the chat printed one thing:
4
+ //
5
+ // ⠋ Thinking…
6
+ //
7
+ // and that was the entire signal for what could be a four-minute, forty-step,
8
+ // several-dollar run. The user could not see elapsed time, which model was
9
+ // spending, how many steps had gone by, how many tokens the turn had burned, or
10
+
11
+ // those numbers was already in memory — the loop broadcasts `usage_update` on
12
+ // every response and the config carries the model — and none of it was shown.
13
+ //
14
+ // openclaw puts elapsed and connection state on its waiting line; opencode keeps
15
+ // a permanent footer with cwd, LSP and MCP counts. This is the same idea in
16
+ // Causyne's register: no whimsy, just the four numbers a person needs to decide
17
+ // whether to let a run continue or press esc.
18
+ //
19
+ // The line degrades by DROPPING SEGMENTS, right to left, until it fits. A narrow
20
+ // terminal loses the cost before it loses the elapsed time, and loses the
21
+ // elapsed time before it loses the message. It never wraps, because a wrapping
22
+ // spinner leaves debris every tick.
23
+ //
24
+ // Pure Node over ./theme.js and ./text.js. No dependency, no shell.
25
+
26
+ import { paint, dim, GLYPHS } from './theme.js';
27
+ import { truncate, visibleWidth, safeDisplay, shortenHome } from './text.js';
28
+ import { contextMeter } from './block.js';
29
+ import { CHARS_PER_TOKEN } from '../contextManager.js';
30
+
31
+ /** 1234 -> "1.2k", 1234567 -> "1.2M". Token counts are read, not summed. */
32
+ export function compactCount(value) {
33
+ const n = Number(value) || 0;
34
+ if (n < 1000) return String(Math.round(n));
35
+ if (n < 1000000) return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k`;
36
+ return `${(n / 1000000).toFixed(1)}M`;
37
+ }
38
+
39
+ /**
40
+ * Money, at the precision the number deserves.
41
+ *
42
+ * Sub-cent runs printed as "$0.00" read as free, which is how a user learns not
43
+ * to look at the cost at all.
44
+ */
45
+ export function formatCost(value) {
46
+ const n = Number(value) || 0;
47
+ if (n <= 0) return '$0';
48
+ if (n < 0.01) return `$${n.toFixed(4)}`;
49
+ if (n < 1) return `$${n.toFixed(3)}`;
50
+ return `$${n.toFixed(2)}`;
51
+ }
52
+
53
+ export function formatElapsed(ms) {
54
+ const total = Math.max(0, Math.round(ms / 1000));
55
+ if (total < 60) return `${(ms / 1000).toFixed(1)}s`;
56
+ const minutes = Math.floor(total / 60);
57
+ const seconds = total % 60;
58
+ if (minutes < 60) return `${minutes}m${String(seconds).padStart(2, '0')}s`;
59
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
60
+ }
61
+
62
+ /** Strip a provider prefix so "anthropic/claude-opus-5" reads as the model. */
63
+ function shortModel(model) {
64
+ const name = String(model || '').split('/').pop() || '';
65
+ return name.length > 22 ? name.slice(0, 21) + GLYPHS.ellipsis : name;
66
+ }
67
+
68
+ export class StatusLine {
69
+ constructor() {
70
+ this.model = '';
71
+ this.provider = '';
72
+ this.tier = '';
73
+ this.cwd = '';
74
+ // Both in CHARACTERS — the unit the window is actually budgeted in.
75
+ // `contextLimit` always was; the numerator was the thing that was wrong.
76
+ this.contextLimit = 0;
77
+ this.contextChars = 0;
78
+ this.reset();
79
+ }
80
+
81
+ /** Session-lifetime facts, set once from the same info as the header. */
82
+ setSession(info = {}) {
83
+ this.model = shortModel(info.model);
84
+ this.provider = info.provider ? String(info.provider) : '';
85
+ this.tier = info.tier ? String(info.tier) : '';
86
+ // The idle footer answers "where am I and how much room is left", which is
87
+ // what opencode's permanent footer shows (cwd left, context right). Neither
88
+ // fact was reachable once the session header had scrolled away.
89
+ this.cwd = info.cwd ? String(info.cwd) : '';
90
+ this.contextLimit = Number(info.contextLimit) || 0;
91
+ return this;
92
+ }
93
+
94
+ /** Per-turn counters. */
95
+ reset() {
96
+ this.turnTokens = 0;
97
+ this.turnCost = 0;
98
+ this.cachedTokens = 0;
99
+ this.contextChars = 0;
100
+ return this;
101
+ }
102
+
103
+ /**
104
+ * The idle footer: where you are, and how much context is left.
105
+ *
106
+ * A separate shape from the working line on purpose. While a turn runs the
107
+ * user is deciding whether to let it continue, so the line carries elapsed,
108
+ * steps, tokens and cost. Between turns none of those are actionable, and the
109
+ * two facts that are — the working directory and the remaining context — had
110
+ * nowhere to live. Same justification as render(): left fact, right numbers.
111
+ */
112
+ renderIdle({ width = 80, hint = '' } = {}) {
113
+ const budget = Math.max(10, width - 1);
114
+ // Characters in, characters out — then one conversion, so both halves of
115
+ // "24K (20%)" describe the same measurement. Feeding this the task's
116
+ // CUMULATIVE token spend and dividing by a character pool is what produced
117
+ // a permanent "290K (99%)" on a window that was nowhere near full.
118
+ const meter = contextMeter(
119
+ Math.round(this.contextChars / CHARS_PER_TOKEN),
120
+ Math.round(this.contextLimit / CHARS_PER_TOKEN)
121
+ );
122
+ const segments = [];
123
+ if (meter) segments.push(meter);
124
+ if (hint) segments.push(hint);
125
+
126
+ const separator = ` ${GLYPHS.dot} `;
127
+ let kept = segments.slice();
128
+ let tail = kept.join(separator);
129
+ // Drop the hint before the number: a truncated keybinding is a wrong one.
130
+ while (kept.length > 1 && visibleWidth(tail) > Math.floor(budget / 2)) {
131
+ kept.pop();
132
+ tail = kept.join(separator);
133
+ }
134
+ if (visibleWidth(tail) > budget) tail = '';
135
+
136
+ const room = tail ? Math.max(0, budget - visibleWidth(tail) - visibleWidth(separator)) : budget;
137
+ const where = this.cwd ? truncate(shortenHome(this.cwd), room, GLYPHS.ellipsis) : '';
138
+ const left = dim(where);
139
+ if (!tail) return left;
140
+
141
+ const gap = Math.max(
142
+ visibleWidth(separator),
143
+ width - 1 - visibleWidth(left) - visibleWidth(tail)
144
+ );
145
+ return `${left}${' '.repeat(gap)}${dim(tail)}`;
146
+ }
147
+
148
+ /**
149
+ * Consume a `usage_update` frame.
150
+ *
151
+ * taskCostUsd is the whole task; costUsd is this response. The turn total is
152
+ * what the user is deciding about right now, so accumulate that.
153
+ */
154
+ onUsage(msg = {}) {
155
+ // TASK scope, explicitly. `usedTokens` now carries the DAILY total, which is
156
+
157
+ // shows — the working line is about the turn the user is deciding on.
158
+ this.turnTokens = Number(msg.taskTokens) || Number(msg.usedTokens) || this.turnTokens;
159
+ // What the request actually carried, measured at the point it was built.
160
+ if (Number(msg.contextChars) > 0) this.contextChars = Number(msg.contextChars);
161
+ if (Number(msg.contextLimitChars) > 0) this.contextLimit = Number(msg.contextLimitChars);
162
+ const responseCost = Number(msg.costUsd) || 0;
163
+ const taskCost = Number(msg.taskCostUsd) || 0;
164
+ // Prefer the task total when the provider reports one — it already includes
165
+ // every response, and summing on top would double-count.
166
+ this.turnCost = taskCost > 0 ? taskCost : this.turnCost + responseCost;
167
+ this.cachedTokens = Number(msg.cachedTokens) || 0;
168
+ return this;
169
+ }
170
+
171
+ /**
172
+ * Build the line.
173
+ *
174
+ * `segments` are ordered most to least important. Anything that does not fit
175
+ * is dropped whole — never truncated into a misleading half-number.
176
+ */
177
+ render({ message = '', elapsedMs = 0, steps = 0, width = 80, spinnerFrame = '' } = {}) {
178
+ const head = spinnerFrame ? `${paint('accent', spinnerFrame)} ` : '';
179
+ const budget = Math.max(10, width - visibleWidth(head) - 1);
180
+
181
+ const segments = [];
182
+ if (elapsedMs > 0) segments.push(formatElapsed(elapsedMs));
183
+ if (steps > 0) segments.push(`step ${steps}`);
184
+ if (this.turnTokens > 0) segments.push(`${compactCount(this.turnTokens)} tok`);
185
+ if (this.turnCost > 0) segments.push(formatCost(this.turnCost));
186
+ if (this.cachedTokens > 0) segments.push(`${compactCount(this.cachedTokens)} cached`);
187
+ if (this.model) segments.push(this.model);
188
+
189
+ const separator = ` ${GLYPHS.dot} `;
190
+ const text = safeDisplay(message);
191
+
192
+ // Drop segments from the right until the message fits at its natural width.
193
+ //
194
+ // The message is what the user is reading; the numbers are what they glance
195
+ // at. Reserving space for the message FIRST is what stops a wide terminal
196
+ // from printing "reading project fi…" next to six perfectly intact counters.
197
+ // The reservation is capped at half the line so a very long status cannot
198
+ // squeeze every number out.
199
+ let kept = segments.slice();
200
+ const wanted = Math.min(visibleWidth(text), Math.max(18, Math.floor(budget / 2)));
201
+ while (kept.length > 0) {
202
+ const tail = kept.join(separator);
203
+ if (visibleWidth(tail) + separator.length + wanted <= budget) break;
204
+ kept.pop();
205
+ }
206
+
207
+ const tail = kept.length > 0 ? kept.join(separator) : '';
208
+ const messageBudget = tail
209
+ ? Math.max(1, budget - visibleWidth(tail) - separator.length)
210
+ : budget;
211
+ const body = truncate(text, messageBudget, GLYPHS.ellipsis);
212
+
213
+ const left = `${head}${paint('muted', body)}`;
214
+ if (!tail) return left;
215
+
216
+ // Justify: message left, numbers hard right.
217
+ //
218
+ // Packing everything against the left edge wastes the whole right half of a
219
+ // wide terminal and makes the counters drift horizontally as the status text
220
+ // changes length — the eye has to re-find them every frame. Pinning them to
221
+ // the right edge means they sit in the same column all session. This is the
222
+ // shape opencode's session footer uses (justifyContent="space-between", the
223
+ // directory left and the counters right).
224
+ //
225
+ // The fitting loop above already guarantees room for a separator between the
226
+ // two, so the gap can never collapse to nothing.
227
+ // Justify against width - 1, not width.
228
+ //
229
+ // The fitting loop above budgets `width - head - 1`, but this gap was
230
+ // computed against the full `width`, so the finished line came back exactly
231
+ // `width` columns — flush against the right edge, with no slack at all. A
232
+ // terminal that renders one of the ambiguous-width glyphs in this line (`·`
233
+ // appears twice) as two columns then wraps it, and a wrapped row in the live
234
+ // region corrupts erase()'s row accounting. One reserved column removes the
235
+ // whole class of failure at its source; ChatPrompt.clampRows() is the net.
236
+ const gap = Math.max(
237
+ visibleWidth(separator),
238
+ width - 1 - visibleWidth(left) - visibleWidth(tail)
239
+ );
240
+ return `${left}${' '.repeat(gap)}${dim(tail)}`;
241
+ }
242
+
243
+ /**
244
+ * The non-TTY form: no spinner, no elapsed clock, printed once.
245
+ *
246
+ * Piped into a CI log an animated line becomes thousands of junk rows, and an
247
+ * elapsed time that never updates is worse than none.
248
+ */
249
+ renderStatic(message, width = 80) {
250
+ const parts = [safeDisplay(message)];
251
+ if (this.model) parts.push(this.model);
252
+ return truncate(`${GLYPHS.dot} ${parts.join(` ${GLYPHS.dot} `)}`, width, GLYPHS.ellipsis);
253
+ }
254
+ }
@@ -0,0 +1,345 @@
1
+ // ANSI-aware text primitives.
2
+ //
3
+ // Every one of these exists because the CLI got a terminal detail wrong:
4
+ //
5
+ // * `padEnd` on a coloured string pads by BYTE length, so escape sequences
6
+ // counted toward the column and every aligned table drifted.
7
+ // * `slice()` on a coloured string can cut through the middle of an escape,
8
+ // leaking `[38;2;96;205` as literal text and leaving the colour stuck on.
9
+ // * Tool output was printed raw — see sanitize() below, which is a security
10
+ // fix, not a cosmetic one.
11
+ //
12
+ // Pure JS. No dependency, no shell.
13
+
14
+ import os from 'os';
15
+
16
+ // CSI sequences (colour, cursor movement, erase) and OSC sequences (window
17
+ // title, hyperlinks — terminated by BEL or ST).
18
+ const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
19
+ const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
20
+ const OTHER_ESCAPE_PATTERN = /\x1b[@-Z\\-_]/g;
21
+
22
+ /** Everything that is an escape sequence rather than visible text. */
23
+ export function stripAnsi(text) {
24
+ return String(text == null ? '' : text)
25
+ .replace(OSC_PATTERN, '')
26
+ .replace(CSI_PATTERN, '')
27
+ .replace(OTHER_ESCAPE_PATTERN, '');
28
+ }
29
+
30
+ /**
31
+ * Does this code point occupy two terminal columns?
32
+ *
33
+ * CJK, Hangul, Kana and most emoji are double-width. Getting this wrong is what
34
+ * makes a table of Indonesian and Chinese filenames drift apart.
35
+ */
36
+ function isWideCodePoint(code) {
37
+ return (
38
+ (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
39
+ (code >= 0x2e80 && code <= 0xa4cf) || // CJK radicals … Yi
40
+ (code >= 0xac00 && code <= 0xd7a3) || // Hangul syllables
41
+ (code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs
42
+ (code >= 0xfe30 && code <= 0xfe6f) || // CJK compatibility forms
43
+ (code >= 0xff00 && code <= 0xff60) || // Fullwidth forms
44
+ (code >= 0xffe0 && code <= 0xffe6) ||
45
+ (code >= 0x1f300 && code <= 0x1f64f) || // emoji
46
+ (code >= 0x1f900 && code <= 0x1f9ff)
47
+ );
48
+ }
49
+
50
+ /**
51
+ * The East Asian AMBIGUOUS class (UAX #11).
52
+ *
53
+ * These render as ONE column beside Latin text and TWO beside CJK, depending on
54
+ * the terminal's font and locale — and every glyph in theme.js GLYPHS is in this
55
+ * class: `·` U+00B7, `▸` U+25B8, `✓` U+2713, `─` U+2500, `│` U+2502, `…` U+2026.
56
+ *
57
+ * Counting them narrow is right for a Western-configured terminal and is what
58
+ * every comparable library does, so that stays the default. But when the count
59
+ * is wrong the row is WIDER than measured, the terminal wraps it, and a wrapped
60
+ * row makes ChatPrompt.drawnRows (logical) disagree with the physical row count
61
+ * — which is what left a doubled hint row and glyph debris on screen. Callers
62
+ * that must not be wrong get the wide reading via the environment.
63
+ */
64
+ function isAmbiguousCodePoint(code) {
65
+ return (
66
+ (code >= 0x00a1 && code <= 0x00ff && code !== 0x00ad) ||
67
+ (code >= 0x2010 && code <= 0x2027) ||
68
+ code === 0x2022 || code === 0x2026 ||
69
+ (code >= 0x2190 && code <= 0x21ff) || // arrows
70
+ (code >= 0x2200 && code <= 0x22ff) || // mathematical operators
71
+ (code >= 0x2460 && code <= 0x24ff) || // enclosed alphanumerics
72
+ (code >= 0x2500 && code <= 0x257f) || // box drawing
73
+ (code >= 0x2580 && code <= 0x259f) || // block elements
74
+ (code >= 0x25a0 && code <= 0x25ff) || // geometric shapes
75
+ (code >= 0x2600 && code <= 0x26ff) || // miscellaneous symbols
76
+ (code >= 0x2700 && code <= 0x27bf) // dingbats (✓ ✕)
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Should ambiguous-width code points be measured as two columns?
82
+ *
83
+ * Read once at import: an explicit override wins, otherwise a CJK locale is the
84
+ * signal, because that is the configuration in which a terminal's font actually
85
+ * renders these glyphs wide.
86
+ */
87
+ function detectAmbiguousWide() {
88
+ const explicit = process.env.CAUSYNE_AMBIGUOUS_WIDE;
89
+ if (explicit === '1') return true;
90
+ if (explicit === '0') return false;
91
+ const locale = String(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || '');
92
+ return /^(ja|ko|zh)([_.-]|$)/i.test(locale);
93
+ }
94
+
95
+ export const AMBIGUOUS_IS_WIDE = detectAmbiguousWide();
96
+
97
+ /** Columns one code point occupies, under the active ambiguous-width policy. */
98
+ function columnsFor(code) {
99
+ if (isWideCodePoint(code)) return 2;
100
+ if (AMBIGUOUS_IS_WIDE && isAmbiguousCodePoint(code)) return 2;
101
+ return 1;
102
+ }
103
+
104
+ /** Columns this string will occupy once printed. Escapes count for nothing. */
105
+ export function visibleWidth(text) {
106
+ const plain = stripAnsi(text);
107
+ let width = 0;
108
+ for (const character of plain) {
109
+ const code = character.codePointAt(0);
110
+ if (code === 0x200d) continue; // zero-width joiner
111
+ if (code >= 0xfe00 && code <= 0xfe0f) continue; // variation selectors
112
+ width += columnsFor(code);
113
+ }
114
+ return width;
115
+ }
116
+
117
+ const RESET = '\x1b[0m';
118
+
119
+ /**
120
+ * Cut to `width` columns without ever splitting an escape sequence.
121
+ *
122
+ * Walks the string escape-aware rather than slicing by index, and closes any
123
+ * style that was still open at the cut so the colour does not bleed into the
124
+ * next line.
125
+ */
126
+ export function truncate(text, width, ellipsis = '…') {
127
+ const source = String(text == null ? '' : text);
128
+ if (width <= 0) return '';
129
+ if (visibleWidth(source) <= width) return source;
130
+
131
+ const ellipsisWidth = visibleWidth(ellipsis);
132
+ const budget = Math.max(0, width - ellipsisWidth);
133
+
134
+ let out = '';
135
+ let used = 0;
136
+ let sawEscape = false;
137
+ let index = 0;
138
+
139
+ while (index < source.length) {
140
+ if (source[index] === '\x1b') {
141
+ // Copy the whole sequence verbatim; it costs no columns.
142
+ const rest = source.slice(index);
143
+ const match = rest.match(/^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/)
144
+ || rest.match(/^\x1b\[[0-?]*[ -/]*[@-~]/)
145
+ || rest.match(/^\x1b[@-Z\\-_]/);
146
+ if (match) {
147
+ out += match[0];
148
+ sawEscape = true;
149
+ index += match[0].length;
150
+ continue;
151
+ }
152
+ }
153
+
154
+ const character = String.fromCodePoint(source.codePointAt(index));
155
+ const charWidth = columnsFor(source.codePointAt(index));
156
+ if (used + charWidth > budget) break;
157
+
158
+ out += character;
159
+ used += charWidth;
160
+ index += character.length;
161
+ }
162
+
163
+ return out + (sawEscape ? RESET : '') + ellipsis;
164
+ }
165
+
166
+ /** Pad to an exact column count, measuring visible width rather than bytes. */
167
+ export function padEnd(text, width) {
168
+ const gap = width - visibleWidth(text);
169
+ return gap > 0 ? String(text) + ' '.repeat(gap) : String(text);
170
+ }
171
+
172
+
173
+ export function wrap(text, width) {
174
+ if (width <= 0) return [String(text)];
175
+ const lines = [];
176
+
177
+ for (const paragraph of String(text == null ? '' : text).split('\n')) {
178
+ if (visibleWidth(paragraph) <= width) {
179
+ lines.push(paragraph);
180
+ continue;
181
+ }
182
+
183
+ let current = '';
184
+ for (const word of paragraph.split(' ')) {
185
+ const candidate = current ? `${current} ${word}` : word;
186
+
187
+ if (visibleWidth(candidate) <= width) {
188
+ current = candidate;
189
+ continue;
190
+ }
191
+ if (current) {
192
+ lines.push(current);
193
+ current = '';
194
+ }
195
+
196
+ let remainder = word;
197
+ while (visibleWidth(remainder) > width) {
198
+ lines.push(truncate(remainder, width, ''));
199
+ // truncate() gives us the head; drop the same number of characters.
200
+ let consumed = 0;
201
+ let cut = 0;
202
+ while (cut < remainder.length && consumed < width) {
203
+ const code = remainder.codePointAt(cut);
204
+ consumed += columnsFor(code);
205
+ cut += String.fromCodePoint(code).length;
206
+ }
207
+ remainder = remainder.slice(cut);
208
+ }
209
+ current = remainder;
210
+ }
211
+ if (current) lines.push(current);
212
+ }
213
+
214
+ return lines;
215
+ }
216
+
217
+ /**
218
+ * Word-wrap a block of text while preserving each line's own indentation.
219
+ *
220
+ * wrap() flattens indentation, which turns a nested list into a wall. This keeps
221
+ * the leading whitespace a line already has and indents its continuation rows by
222
+ * `hang` more, so a wrapped `/skills` entry still reads as one entry.
223
+ *
224
+ * Exists because command output was written to the terminal completely unwrapped
225
+ * — a `/skills` description reached 195 columns on a 102-column terminal and the
226
+ * terminal broke it at the right edge, mid-word, at column 0.
227
+ */
228
+ export function wrapBlock(text, width, { hang = 2 } = {}) {
229
+ const rows = [];
230
+ for (const line of String(text == null ? '' : text).split('\n')) {
231
+ const indent = (line.match(/^[ \t]*/) || [''])[0];
232
+ const body = line.slice(indent.length);
233
+ if (!body) { rows.push(''); continue; }
234
+
235
+ const continuation = indent + ' '.repeat(hang);
236
+ // Wrap at the NARROWER of the two budgets so neither the first row nor a
237
+ // continuation row can exceed `width`.
238
+ const budget = Math.max(8, width - visibleWidth(continuation));
239
+ const parts = wrap(body, budget);
240
+ rows.push(indent + parts[0]);
241
+ for (const part of parts.slice(1)) rows.push(continuation + part);
242
+ }
243
+ return rows;
244
+ }
245
+
246
+ /**
247
+ * Make untrusted text safe to print.
248
+ *
249
+ * SECURITY, not cosmetics. Tool output reaches the terminal directly: a file
250
+ * read by fs_read, or the stdout of sysRun, is attacker-influenced content. If
251
+ * it carries escape sequences it can move the cursor, erase the screen, or
252
+ * overwrite lines that are already printed — including repainting an approval
253
+ * prompt the user just answered. Stripping escapes is what makes "the user saw
254
+ * what happened" true.
255
+ *
256
+ * Newlines and tabs survive; every other control character does not.
257
+ */
258
+ /** Columns between terminal tab stops. Eight is the POSIX default everywhere. */
259
+ const TAB_WIDTH = 8;
260
+
261
+ /**
262
+ * Replace tabs with the spaces the terminal would have moved over.
263
+ *
264
+ * A TAB MOVES THE CURSOR WITHOUT WRITING ANYTHING. In a live region that
265
+ * redraws in place, that is not a formatting detail — it is corruption. The
266
+ * cursor jumps over cells the previous frame wrote and leaves them on screen,
267
+ * so `sw_vers` output rendered as:
268
+ *
269
+ * ProductName:P_FIX_PLAN.mdmacOS
270
+ * ProductVersion:EGOTIATION26.5.2
271
+ *
272
+ * where `P_FIX_PLAN.md` and `EGOTIATION` are the tails of filenames printed by
273
+ * the PREVIOUS command. Spaces overwrite; tabs do not.
274
+ *
275
+ * It also breaks measurement, which is worse. visibleWidth() counted a tab as
276
+ * one column while the terminal advanced up to eight — on that line the error
277
+ * was ten columns. A row the renderer believes fits therefore wraps, erase()
278
+ * miscounts how many rows to walk back, and it eats real conversation above the
279
+ * prompt. That is the live-region width invariant, broken by one byte.
280
+ *
281
+ * Tab stops are per LINE and reset after every newline, so this tracks the
282
+ * column rather than substituting a fixed run of spaces — which is what keeps
283
+ * columnar output actually lining up.
284
+ */
285
+ export function expandTabs(text, tabWidth = TAB_WIDTH) {
286
+ const source = String(text == null ? '' : text);
287
+ if (!source.includes('\t')) return source;
288
+
289
+ let out = '';
290
+ let column = 0;
291
+ for (const char of source) {
292
+ if (char === '\t') {
293
+ const advance = tabWidth - (column % tabWidth);
294
+ out += ' '.repeat(advance);
295
+ column += advance;
296
+ } else if (char === '\n') {
297
+ out += char;
298
+ column = 0;
299
+ } else {
300
+ out += char;
301
+ column += 1;
302
+ }
303
+ }
304
+ return out;
305
+ }
306
+
307
+ export function sanitize(text) {
308
+ // Tabs are expanded rather than stripped: removing them would collapse
309
+ // columnar output into an unreadable run, and leaving them corrupts the
310
+ // frame. Expansion is the only option that preserves what the user meant to
311
+ // see. Everything else in this class is genuinely unwanted and is removed.
312
+ return expandTabs(stripAnsi(text))
313
+ // eslint-disable-next-line no-control-regex
314
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
315
+ }
316
+
317
+ /** `/Users/example/Projects/x` → `~/Projects/x`. Long paths dominate a tool row. */
318
+ export function shortenHome(value) {
319
+ const home = os.homedir();
320
+ const text = String(value == null ? '' : value);
321
+ if (home && text.startsWith(home)) return '~' + text.slice(home.length);
322
+ return text;
323
+ }
324
+
325
+
326
+ const SECRET_PATTERNS = [
327
+ [/\b(sk|pk|rk)-[A-Za-z0-9_-]{12,}/g, (m) => `${m.slice(0, 5)}…${m.slice(-4)}`],
328
+ [/\b(gh[pousr]|xox[baprs])_[A-Za-z0-9]{12,}/g, (m) => `${m.slice(0, 7)}…`],
329
+ [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi, (m) => `${m.split(/\s+/)[0]} …`],
330
+ [/\b(api[_-]?key|token|secret|password|passwd)\s*[=:]\s*["']?[^\s"']{6,}/gi,
331
+ (m) => `${m.split(/[=:]/)[0]}=…`]
332
+ ];
333
+
334
+ export function redactSecrets(text) {
335
+ let out = String(text == null ? '' : text);
336
+ for (const [pattern, replace] of SECRET_PATTERNS) {
337
+ out = out.replace(pattern, replace);
338
+ }
339
+ return out;
340
+ }
341
+
342
+ /** sanitize + redact — the pipeline every untrusted string goes through. */
343
+ export function safeDisplay(text) {
344
+ return redactSecrets(sanitize(text));
345
+ }