@causyne/agent 0.0.0-stage → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
package/src/cliMode.js ADDED
@@ -0,0 +1,638 @@
1
+ import { AgentLoop } from './agentLoop.js';
2
+ import { loadConfig, saveConfig } from './config.js';
3
+ import inquirer from './utils/inquirer.js';
4
+ import readline from 'readline';
5
+ import { ChatPrompt, isInteractive } from './tui/prompt.js';
6
+ import { handleInput, listCommands, isCommandInput } from './commands.js';
7
+ import { ChatRenderer } from './tui/chatRenderer.js';
8
+ import { paint, bold, badge, GLYPHS, terminalWidth } from './tui/theme.js';
9
+ import { wordmark } from './tui/banner.js';
10
+ import { createStageSurface } from './tui/stageSurface.js';
11
+ import { wrapBlock } from './tui/text.js';
12
+ import { getPackageVersion } from './version.js';
13
+ import { resolveCharPool } from './contextManager.js';
14
+ import { PRIMARY_ACTIONS, homeStatusLine, homeHint, moreLayer, configureInPrompt, mcpPicker, PROVIDERS } from './tui/home.js';
15
+ import { askSelect, askText, askConfirm } from './tui/ask.js';
16
+ import { renderReport } from './reporting.js';
17
+ import { grantAlways } from './approvalGrants.js';
18
+ import { auditLog } from './auditLog.js';
19
+
20
+ // One themed source for the art.
21
+ //
22
+ // This used to be a second copy of the block letters as a raw
23
+ // `\x1b[1m\x1b[36m ...` string, so the scrollback path ignored NO_COLOR — exactly
24
+ // the regression tests/tuiScreen.test.js forbids for the wordmark. Both paths now
25
+ // render the same rows through the theme.
26
+ function logoLines() {
27
+ return wordmark(terminalWidth()).join('\n') + '\n';
28
+ }
29
+
30
+ // Local event sink. Rendering and approvals do not open network connections.
31
+ export class LocalCLIConnection {
32
+ constructor(renderer = null) {
33
+ this.readyState = 1;
34
+ this.renderer = renderer || new ChatRenderer();
35
+ this.pendingApprovals = this.renderer.pendingApprovals;
36
+ }
37
+
38
+ close() {
39
+ this.readyState = 3;
40
+ this.stopSpinner();
41
+ }
42
+
43
+ get stepCount() { return this.renderer.stepCount; }
44
+ set stepCount(value) { this.renderer.stepCount = value; }
45
+
46
+ stopSpinner() { this.renderer.stopSpinner(); }
47
+
48
+ send(data) {
49
+ let msg;
50
+ try {
51
+ msg = JSON.parse(data);
52
+ } catch (e) {
53
+ return; // not a frame we produced
54
+ }
55
+
56
+ try {
57
+ this.renderer.handle(msg);
58
+ } catch (e) {
59
+ // This used to be `catch(e) {}` around the whole method, so a render bug
60
+ // silently ate the frame and the transcript just went quiet. Report it
61
+ // once, on stderr, and keep the session alive.
62
+ process.stderr.write(`[tui] render failed for "${msg.type}": ${e.message}\n`);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Ask the human what the agent is about to do — and say what it is.
69
+ *
70
+ * L6 (local-path audit): both call sites used to prompt with a bare
71
+ * "Action requires approval. Proceed?", which tells the user nothing and trains
72
+ * them to press y. The detail comes from the approval_request frame the loop
73
+ * already emits; LocalCLIConnection now keeps it.
74
+ */
75
+ function makeApprovalHandler(conn, dialogs = null) {
76
+ return async (toolCallId) => {
77
+ const detail = conn.pendingApprovals ? conn.pendingApprovals.get(toolCallId) : null;
78
+ conn.stopSpinner();
79
+
80
+ // An interactive session asks in a panel that shows the risk, the arguments
81
+ // and a scrollable diff in one frame. The inquirer path below asks the same
82
+ // question in the scrollback, which is all a non-TTY session can do.
83
+ if (dialogs && detail) {
84
+ // Loops only for "Allow always" → Cancel: backing out of the standing
85
+ // grant returns you to the question, it does not silently deny the call.
86
+ // Every other path returns on the first pass.
87
+ let approved = false;
88
+ let scope = 'reject';
89
+
90
+ for (;;) {
91
+ const answer = await dialogs.approve(detail);
92
+ approved = Boolean(answer && answer.approved);
93
+ scope = (answer && answer.scope) || 'reject';
94
+
95
+ if (scope !== 'always') break;
96
+
97
+ // A SECOND, EXPLICIT CONFIRMATION — the same shape opencode uses, and
98
+ // for the same reason: a standing grant is a much larger thing than
99
+ // approving one call, so it must not be reachable by one keystroke.
100
+ // The text says exactly what is being given up and for how long.
101
+ const confirmed = await dialogs.confirm(
102
+ `Allow ${bold(detail.tool)} to run WITHOUT asking, for the rest of this session?\n`
103
+ + ` This lasts until Causyne restarts. Undo it any time with /approvals clear.`
104
+ );
105
+ if (confirmed) break;
106
+ // Cancelled: ask again rather than denying something they never rejected.
107
+ }
108
+
109
+ if (approved && scope === 'always') {
110
+ grantAlways(detail.tool);
111
+ auditLog.log({ event: 'approval_granted_always', tool: detail.tool, risk: detail.risk });
112
+ }
113
+
114
+ // The transcript records the DECISION, not a second copy of the question.
115
+ // onApprovalRequest() skips its block whenever dialogWillAsk is set, so
116
+ // without this line the scrollback would forget the exchange entirely.
117
+ if (conn.renderer && typeof conn.renderer.renderApprovalDecision === 'function') {
118
+ conn.renderer.renderApprovalDecision(detail, approved, scope);
119
+ }
120
+ if (conn.pendingApprovals) conn.pendingApprovals.delete(toolCallId);
121
+ return approved;
122
+ }
123
+
124
+ let message = 'Approve this action?';
125
+ if (detail) {
126
+ // No risk word here either — the question itself is the signal.
127
+ message = `Allow ${bold(detail.tool)} to run once?`;
128
+ } else if (String(toolCallId).startsWith('circuit_breaker_')) {
129
+ message = 'The agent has run 100 steps. Let it continue?';
130
+ } else if (String(toolCallId).startsWith('tier_extension_')) {
131
+ message = 'Step budget reached. Unlock another batch?';
132
+ }
133
+
134
+ if (dialogs) {
135
+ const approved = await dialogs.confirm(message);
136
+ if (conn.pendingApprovals) conn.pendingApprovals.delete(toolCallId);
137
+ return approved;
138
+ }
139
+
140
+ // No live region to draw a panel into (a piped session, or raw mode
141
+ // refused). askConfirm degrades to readline on its own, so this path keeps
142
+ // the same wording as the panel instead of inquirer's.
143
+ const approved = await askConfirm(message);
144
+
145
+ if (conn.pendingApprovals) conn.pendingApprovals.delete(toolCallId);
146
+ return approved;
147
+ };
148
+ }
149
+
150
+ /** Muted one-liner for menu feedback that is not success and not an error. */
151
+ function dimNotice(text) { return paint('muted', text); }
152
+
153
+ /**
154
+ * Draw the home menu above the input line.
155
+ *
156
+ * The status row is committed as ordinary scrollback (UX-01: everything printed
157
+ * is permanent history) while the menu itself lives in the live region, so it
158
+ * erases cleanly when it is dismissed and leaves the summary behind.
159
+ */
160
+ function openHomeMenu(prompt, conn) {
161
+ const config = loadConfig();
162
+ conn.renderer.write('\n' + homeStatusLine({ config }) + '\n');
163
+
164
+ prompt.openHome(
165
+ PRIMARY_ACTIONS.map((action) => ({ id: action.id, label: action.label, hint: action.hint, action })),
166
+ (item) => { prompt.deliver({ homeAction: item.action }); }
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Run one menu action.
172
+ *
173
+ * Nothing here reimplements a command. A 'stay' action calls the same builder
174
+ * the subcommand calls, a 'command' action is routed through the slash registry
175
+ * so it inherits that command's confirmation gates, and a 'takeover' hands the
176
+ * terminal over after stopping the prompt. That is what keeps the menu from
177
+ * becoming a second, subtly different CLI.
178
+ */
179
+ async function runHomeAction(action, { prompt, conn, finish }) {
180
+ const reopen = () => { if (prompt) openHomeMenu(prompt, conn); };
181
+
182
+ switch (action.kind) {
183
+ case 'chat':
184
+ // Closing the menu already put the caret on the input line.
185
+ return null;
186
+
187
+ case 'stay': {
188
+ const text = await action.run();
189
+ conn.renderer.write('\n' + renderReport(text, { force: true }) + '\n');
190
+ reopen();
191
+ return null;
192
+ }
193
+
194
+ case 'more': {
195
+ if (!prompt) return null;
196
+ prompt.openLayer(moreLayer((picked) => {
197
+ if (picked) prompt.deliver({ homeAction: picked });
198
+ else reopen();
199
+ }));
200
+ return null;
201
+ }
202
+
203
+ case 'command': {
204
+ const result = await handleInput(action.command, { surface: 'terminal', prompt });
205
+ if (result) {
206
+ const wrapped = wrapBlock(result.output, conn.renderer.width()).join('\n');
207
+ conn.renderer.write('\n' + (result.ok ? wrapped : paint('danger', wrapped)) + '\n');
208
+ }
209
+ reopen();
210
+ return null;
211
+ }
212
+
213
+ case 'config': {
214
+ if (!prompt) { await interactiveConfig({}); return 'exit'; }
215
+ const chosen = await configureInPrompt(prompt, { loadConfig, saveConfig });
216
+ conn.renderer.write('\n' + (chosen
217
+ ? paint('success', ` Saved. Using ${chosen.provider} / ${chosen.model}.`)
218
+ : dimNotice(' Cancelled. Nothing was changed.')) + '\n');
219
+ reopen();
220
+ return null;
221
+ }
222
+
223
+ case 'mcp': {
224
+ if (!prompt) return null;
225
+ const { empty } = await mcpPicker(prompt, async (server) => {
226
+ // Toggle the grant, through the same registry `/mcp` uses, so a server
227
+ // enabled from this menu is enabled in exactly the same sense.
228
+ const verb = server.granted ? 'revoke' : 'grant';
229
+ const result = await handleInput(`/mcp ${verb} ${server.name}`, { surface: 'terminal' });
230
+ if (result) conn.renderer.write('\n' + wrapBlock(result.output, conn.renderer.width()).join('\n') + '\n');
231
+ reopen();
232
+ });
233
+ if (empty) {
234
+ conn.renderer.write('\n' + dimNotice(' No MCP servers configured. Add one with `/mcp add <name> <command…>`.') + '\n');
235
+ reopen();
236
+ }
237
+ return null;
238
+ }
239
+
240
+ case 'takeover':
241
+ default: {
242
+ // The action owns the terminal, finishes, and the session ends with it.
243
+ // `update` is the real case: the binary changed underneath us, so carrying
244
+ // on in a half-replaced process would be worse than exiting.
245
+ if (finish) finish();
246
+ await action.run();
247
+ return 'exit';
248
+ }
249
+ }
250
+ }
251
+
252
+ export async function runTask(task) {
253
+ console.log(`\x1b[1m\x1b[36m[TASK]\x1b[0m Executing...\nTask: ${task}`);
254
+
255
+ const conn = new LocalCLIConnection();
256
+ const taskId = `cli_task_${Date.now()}`;
257
+
258
+ const loop = new AgentLoop(conn, taskId, task);
259
+
260
+ // Local human-in-the-loop, showing WHAT is being approved (see L6).
261
+ loop.waitForApproval = makeApprovalHandler(conn);
262
+
263
+ await loop.run();
264
+ console.log('\n\x1b[32m[DONE]\x1b[0m Task completed.\n');
265
+ process.exit(0);
266
+ }
267
+
268
+
269
+ /**
270
+ * Chat as a full-screen application.
271
+ *
272
+ * The scrollback renderer below is still the one producing every line — the
273
+ * markdown, the tool rows, the diffs, the redaction all come from ChatRenderer
274
+ * unchanged. What differs is where those lines land: a Transcript that can
275
+ * scroll and be searched, drawn into a cell grid that repaints only the cells
276
+ * that changed, under a modal stack that owns the keyboard while a dialog is up.
277
+ */
278
+ export async function chatMode({ home = false } = {}) {
279
+
280
+ // One chat surface, and it renders in the NORMAL buffer.
281
+ //
282
+ // There used to be a full-screen alternate-buffer shell here, chosen by
283
+ // canRunShell(). It clears the terminal on entry (\x1b[?1049h + \x1b[2J) and
284
+ // there is no way to detect whether a terminal honours the alternate buffer —
285
+ // ignoring \x1b[?1049h is silent, so nothing throws and nothing falls back.
286
+ // Where it was ignored, the clear wiped the user's screen into their scrollback
287
+ // and the app painted a full-screen layout into their history: a blank block
288
+ // between their shell prompt and the banner, reported twice as "there is still
289
+ // a gap here".
290
+ //
291
+ // Neither reference does this. openclaw emits \x1b[?1049h nowhere at all and
292
+ // repaints the normal buffer in place; opencode's --mini runs split-footer on
293
+ // the main screen with clearOnShutdown false. So does this now: everything
294
+ // printed is permanent terminal history, and only the prompt and its status
295
+ // row are repainted. See UX-01 in .agents/rules.json.
296
+ const conn = new LocalCLIConnection();
297
+ const taskId = `cli_chat_${Date.now()}`;
298
+ const loop = new AgentLoop(conn, taskId, null);
299
+
300
+ // The context the CLI never showed: which model is about to spend the user's
301
+ // money, in which autonomy mode, over which paths. All of it was already
302
+ // loaded — none of it was ever displayed.
303
+ const config = loadConfig();
304
+ const bannerWidth = conn.renderer.sessionHeader({
305
+ version: `v${getPackageVersion()}`,
306
+ provider: config.provider,
307
+ model: config.model,
308
+ autonomyMode: config.autonomyMode,
309
+ tier: config.tier,
310
+ workspace: loop.sandbox && loop.sandbox.grantAllAccess
311
+ ? 'ALL ACCESS'
312
+ : ((loop.sandbox && loop.sandbox.workspacePaths) || [process.cwd()]).join(', '),
313
+ commandCount: listCommands('terminal').length,
314
+ // The idle footer's two facts. Both were already loaded and neither was ever
315
+ // displayed once the header had scrolled away.
316
+ cwd: process.cwd(),
317
+ contextLimit: resolveCharPool(config.tier, config.maxCharPool)
318
+ });
319
+
320
+ loop.waitForApproval = makeApprovalHandler(conn);
321
+
322
+ // The prompt owns the keyboard so the chat can offer history, completion and
323
+ // typing while a turn runs. It is skipped when stdin is not a terminal (piped
324
+ // input, CI) or when CAUSYNE_SIMPLE_INPUT=1, and any failure to take raw mode
325
+ // falls back to the inquirer path below — a better editor must never be the
326
+ // reason chat refuses to start.
327
+ let prompt = null;
328
+ if (isInteractive()) {
329
+ try {
330
+ // The Stage, if this terminal can hold one.
331
+ //
332
+ // It is opened AFTER the session header, deliberately: the wordmark stays
333
+ // the first thing written to stdout and lands in the user's real history,
334
+ // and the band opens below it. That keeps the UX-01 check literally true
335
+ // and gives a seam the user can see — their shell prompt above, Causyne's
336
+ // region below — instead of a screen that has silently become ours.
337
+ //
338
+ // Every null from here is a terminal that should keep the scrollback
339
+ // renderer: not a TTY, TERM=dumb, CAUSYNE_STAGE=off, or too short to hold a
340
+ // viewport. There is no degraded stage.
341
+ // The banner reports the width it actually used. The opening composition
342
+ // is laid out to it, so the input box ends where the banner's text ends
343
+ // instead of stretching past it — and the banner itself is never narrowed,
344
+ // because at 117 columns it already starts hiding the workspace paths.
345
+ const surface = createStageSurface({ config, openingWidth: bannerWidth });
346
+ if (surface) conn.renderer.transcript = surface.transcript;
347
+
348
+ prompt = new ChatPrompt({
349
+ root: process.cwd(),
350
+ commands: listCommands('terminal'),
351
+ // The rule for "is this a command" lives in commands.js and is handed
352
+ // down rather than reimplemented, so the prompt draws a `/usage` as an
353
+ // invocation instead of as a sentence the user supposedly said.
354
+ isCommand: isCommandInput,
355
+ surface,
356
+ onInterrupt: () => loop.abort(),
357
+ onExit: () => { if (prompt) prompt.stop(); process.exit(0); },
358
+ onExpand: () => conn.renderer.expandLastOutput()
359
+ });
360
+ if (surface) prompt.attachSurface(surface);
361
+ prompt.attach(conn.renderer);
362
+ prompt.start();
363
+ // The band is blank until something paints it, so the Stage needs a first
364
+ // frame here. The scrollback path deliberately does NOT get one: there it
365
+ // has always waited for the first keystroke, and changing that as a side
366
+ // effect of this would be a second behaviour change riding along with the
367
+ // one that was asked for.
368
+ if (surface) prompt.draw();
369
+
370
+ // Ask for approval in the prompt's own panel, not inquirer's.
371
+ //
372
+ // inquirer.prompt() takes stdin while this prompt is already holding it in
373
+ // raw mode: the two contend, and the question can land on a half-drawn
374
+ // prompt. The panel shows the risk, the arguments and a scrollable diff in
375
+ // one frame, and dismissing it denies.
376
+ loop.waitForApproval = makeApprovalHandler(conn, prompt);
377
+ // From here the modal asks the question, so the renderer must stop
378
+ // printing the full request into the transcript as well — see
379
+ // ChatRenderer.onApprovalRequest().
380
+ if (conn.renderer) conn.renderer.dialogWillAsk = true;
381
+ // The key hints are part of the session header now — one hint row, not
382
+ // three stacked lines of chrome above the first prompt.
383
+
384
+ // Home is chat with the menu already open. Doing it here rather than in a
385
+ // separate module is deliberate: a parallel home implementation would be a
386
+ // second place for the live region, the completion picker and the approval
387
+ // stack to be got wrong, and the two would drift on the first change.
388
+ if (home) openHomeMenu(prompt, conn);
389
+ } catch (e) {
390
+ process.stderr.write(`[tui] falling back to simple input: ${e.message}\n`);
391
+ prompt = null;
392
+ }
393
+ }
394
+
395
+ const readTask = async () => {
396
+ if (prompt) return prompt.read();
397
+ const { task } = await inquirer.prompt([{
398
+ type: 'input',
399
+ name: 'task',
400
+ message: '\x1b[36mYou:\x1b[0m'
401
+ }]);
402
+ return task;
403
+ };
404
+
405
+ const finish = () => { if (prompt) prompt.stop(); };
406
+
407
+ while (true) {
408
+ let task = await readTask();
409
+
410
+ // A menu pick arrives through the same read() a typed line does, so there
411
+ // is one place the loop waits and one place it dispatches.
412
+ if (task && typeof task === 'object' && task.homeAction) {
413
+ const outcome = await runHomeAction(task.homeAction, { prompt, conn, finish });
414
+ if (outcome === 'exit') break;
415
+ continue;
416
+ }
417
+
418
+ if (typeof task !== 'string') continue;
419
+
420
+ if (['exit', 'quit'].includes(task.trim().toLowerCase())) {
421
+ finish();
422
+ break;
423
+ }
424
+ if (!task.trim()) continue;
425
+
426
+ // A leading slash is a command, not a task. handleInput returns null for
427
+ // anything else, which is what sends the line to the agent — so a prompt
428
+ // that merely mentions a path like /usr/local is still a task.
429
+ const commandResult = await handleInput(task, { surface: 'terminal', loop, prompt });
430
+ if (commandResult) {
431
+ // Route command output through the renderer, not console.log.
432
+ //
433
+ // console.log bypasses the prompt's onBeforeWrite/onAfterWrite hooks, so a
434
+ // multi-line result (e.g. /skills) printed straight over the still-drawn
435
+ // live region — the output landed on top of the status row and the prompt
436
+ // was left doubled below it. renderer.write() lifts the live region out of
437
+ // the way, commits the output as real scrollback, then redraws the prompt
438
+ // beneath it. In the non-TTY fallback the hooks are undefined and this is
439
+ // just a plain write.
440
+ //
441
+ // The output is also WRAPPED to the terminal width before it is written.
442
+ // It used to go out verbatim: a `/skills` description reached 195 columns
443
+ // on a 102-column terminal, the terminal broke it at the right edge, and
444
+ // the fragments landed at column 0 in the middle of the next entry.
445
+ // An interactive terminal browses 100 skills in a picker; a piped session
446
+
447
+ // surfaces, and the text form remains the fallback rather than a
448
+ // second implementation that can disagree.
449
+ if (commandResult.effect === 'pick-skill' && prompt) {
450
+ // Picking a skill puts it on the input line rather than running it
451
+ // straight away, so the user can add what they want done with it. That
452
+ // is the difference between a launcher and a document viewer.
453
+ prompt.browseSkills(commandResult.items, (skill) => {
454
+ prompt.setText(`/skills ${skill.domain}/${skill.id} `);
455
+ });
456
+ continue;
457
+ }
458
+
459
+ // A skill was invoked: it becomes part of the REQUEST, not terminal output.
460
+ //
461
+ // This is the whole point. `/skills <id>` used to print the file and call
462
+ // continue — the body never reached a model, the trailing instruction was
463
+ // discarded, and no API call happened at all. Now the body is attached to
464
+ // the loop as a system message and the turn runs normally, so a skill is
465
+ // a working method the agent follows rather than a document it shows you.
466
+ if (commandResult.effect === 'invoke-skill') {
467
+ loop.activeSkill = commandResult.skill;
468
+ conn.renderer.skillInvoked(commandResult.skill.id, commandResult.instruction);
469
+ task = commandResult.instruction
470
+ || `Apply the ${commandResult.skill.id} skill to the current workspace.`;
471
+ // Deliberately NOT `continue` — fall through to the agent turn below.
472
+ } else {
473
+ // Every other command is terminal output and nothing more.
474
+ const width = conn.renderer.width();
475
+ const wrapped = wrapBlock(commandResult.output, width).join('\n');
476
+ const body = commandResult.ok ? wrapped : paint('danger', wrapped);
477
+ conn.renderer.write('\n' + body + '\n');
478
+ if (commandResult.effect === 'exit') { finish(); break; }
479
+ continue;
480
+ }
481
+ }
482
+
483
+ loop.userPrompt = task;
484
+ // ONE USER REQUEST IS ONE TASK.
485
+ //
486
+ // The loop object is reused across the whole chat — that is deliberate, it
487
+ // carries config and connection state — but its taskId was assigned ONCE at
488
+ // session start and never rotated. Checkpoints group by taskId, so every
489
+ // file every turn ever touched collapsed into a single group: `/undo` in a
490
+ // terminal that had been open all day offered to roll back the WHOLE DAY as
491
+ // one unit, labelled with a prompt from hours earlier. The real store shows
492
+ // it — `cli_chat_1788970595481` holds both `hi` and `continue`.
493
+ //
494
+
495
+ // (`globalAgentLoop.taskId = message.taskId`). The terminal path simply
496
+ // never did.
497
+ loop.taskId = `cli_chat_${Date.now()}`;
498
+
499
+ conn.renderer.resetTurn();
500
+ const startedAt = Date.now();
501
+ if (prompt) prompt.setBusy(true);
502
+ try {
503
+ await loop.run();
504
+ } finally {
505
+ // Must be released on the error path too, or one failed turn leaves the
506
+ // prompt permanently queueing and the chat looks hung.
507
+ if (prompt) prompt.setBusy(false);
508
+ }
509
+ conn.stopSpinner();
510
+ // One turn, one skill: an invocation must not silently steer every later
511
+ // message in the session.
512
+ loop.activeSkill = null;
513
+ conn.renderer.turnFooter(Date.now() - startedAt);
514
+ }
515
+
516
+ process.exit(0);
517
+ }
518
+
519
+ export async function execCommand(command) {
520
+ // L1 (local-path audit): this used to call sysRun.execute() DIRECTLY, so
521
+ // `causyne-agent exec` was the one path into the shell with no sandbox at all —
522
+ // no risk classification, no approval, not even the FORBIDDEN blocklist. A
523
+ // fork bomb or `curl … | bash` ran unchallenged, while the identical command
524
+ // from the agent loop was blocked. Same guard, same command, same answer.
525
+ const { Sandbox } = await import('./sandbox.js');
526
+ const sandbox = new Sandbox();
527
+ const validation = sandbox.validate('sysRun', { command });
528
+
529
+ if (!validation.allowed) {
530
+ console.error(`\x1b[31m[BLOCKED]\x1b[0m ${validation.reason}`);
531
+ process.exit(1);
532
+ }
533
+
534
+ // Human-in-the-loop for anything the classifier calls risky. This is a
535
+ // terminal, so the human is right here — ask them.
536
+ if (validation.risk === 'extreme' || validation.risk === 'dangerous') {
537
+ const approved = await askConfirm(
538
+ `${paint('warn', GLYPHS.warnMark)} Run: ${command}\n Allow once?`
539
+ );
540
+ if (!approved) {
541
+ console.log('Cancelled.');
542
+ process.exit(1);
543
+ }
544
+ }
545
+
546
+ console.log(`Running: ${command}`);
547
+ const { sysRun } = await import('./tools/sysRun.js');
548
+ const result = await sysRun.execute({ command });
549
+ if (result.success) {
550
+ console.log(result.output);
551
+ } else {
552
+ console.error(result.error || result.output);
553
+ }
554
+ process.exit(result.success ? 0 : 1);
555
+ }
556
+
557
+ export async function interactiveConfig(options) {
558
+ const config = loadConfig();
559
+
560
+ console.log(logoLines() + '\n' + bold('Configuration') + '\n');
561
+
562
+ if (options.provider || options.apikey || options.model) {
563
+ if (options.provider) config.provider = options.provider;
564
+ if (options.apikey) config.apiKey = options.apikey;
565
+ if (options.model) config.model = options.model;
566
+ saveConfig(config);
567
+ console.log(paint('success', ' Configuration saved via flags.') + '\n');
568
+ return;
569
+ }
570
+
571
+ // Asked in the product's own panels rather than inquirer's.
572
+ //
573
+ // These were the last two surfaces on a second design system — different
574
+ // colours, different glyphs, no NO_COLOR handling, and a list widget nothing
575
+ // like the picker the same user sees inside `causyne`. askSelect/askText host a
576
+ // real dialog on a temporary ChatPrompt and fall back to readline wherever raw
577
+ // mode is unavailable. PROVIDERS is shared with the in-chat config flow, so
578
+ // the two can no longer offer different lists.
579
+ const provider = await askSelect({
580
+ title: 'AI provider',
581
+ items: PROVIDERS.map((entry) => ({ id: entry.id, label: entry.label, description: entry.hint }))
582
+ });
583
+ if (!provider) { console.log(' Cancelled. Nothing was changed.\n'); return; }
584
+
585
+ const preset = PROVIDERS.find((entry) => entry.id === provider.id) || { model: '' };
586
+ const suggested = (config.provider === provider.id && config.model) ? config.model : preset.model;
587
+
588
+ const model = await askText({
589
+ title: 'model',
590
+ lines: [
591
+ `${provider.label}`,
592
+ '',
593
+ 'Model ids change often. Type the one you want, or accept the suggestion.'
594
+ ],
595
+ value: suggested,
596
+ placeholder: 'model id'
597
+ });
598
+ if (model === null) { console.log(' Cancelled. Nothing was changed.\n'); return; }
599
+
600
+ const apiKey = await askText({
601
+ title: 'API key',
602
+ lines: [
603
+ `${provider.label} ${GLYPHS.arrow} ${model || suggested}`,
604
+ '',
605
+ 'Stored encrypted in ~/.causyne/config.json. It never leaves this machine.'
606
+ ],
607
+ mask: true,
608
+ placeholder: config.apiKey ? 'blank keeps the current key' : 'paste your key'
609
+ });
610
+ if (apiKey === null) { console.log(' Cancelled. Nothing was changed.\n'); return; }
611
+
612
+ // Only asked where it means something. Every provider used to be shown this,
613
+ // and for eight of the ten the right answer is always blank.
614
+ let baseUrl = config.baseUrl || '';
615
+ if (provider.id === 'openai-compatible' || provider.id === 'dashscope-byok' || provider.id === 'ollama') {
616
+ const answer = await askText({
617
+ title: 'base URL',
618
+ lines: ['Leave blank for the provider default.', 'Required for a custom DashScope workspace endpoint.'],
619
+ value: baseUrl,
620
+ placeholder: 'https://…'
621
+ });
622
+ if (answer === null) { console.log(' Cancelled. Nothing was changed.\n'); return; }
623
+ baseUrl = answer;
624
+ }
625
+
626
+ config.provider = provider.id;
627
+ config.model = (model || suggested || '').trim();
628
+ if (apiKey) config.apiKey = apiKey;
629
+ if (baseUrl && baseUrl.trim()) config.baseUrl = baseUrl.trim(); else delete config.baseUrl;
630
+
631
+ saveConfig(config);
632
+ console.log('\n' + paint('success', ` Saved. Using ${config.provider} / ${config.model}.`) + '\n');
633
+
634
+ // Seamlessly transition to the home screen.
635
+ //
636
+ // Finishing configuration returns to the local home screen.
637
+ await chatMode({ home: true });
638
+ }