@aragon-agent/cli 0.4.3

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 (156) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +558 -0
  3. package/dist/agent/coalesce.d.ts +14 -0
  4. package/dist/agent/coalesce.js +41 -0
  5. package/dist/agent/controller.d.ts +132 -0
  6. package/dist/agent/controller.js +332 -0
  7. package/dist/agent/headless.d.ts +22 -0
  8. package/dist/agent/headless.js +74 -0
  9. package/dist/agent/reducer.d.ts +181 -0
  10. package/dist/agent/reducer.js +420 -0
  11. package/dist/agent/system-prompt.d.ts +30 -0
  12. package/dist/agent/system-prompt.js +58 -0
  13. package/dist/agent/usage.d.ts +13 -0
  14. package/dist/agent/usage.js +41 -0
  15. package/dist/cli.d.ts +11 -0
  16. package/dist/cli.js +482 -0
  17. package/dist/commands/builtins.d.ts +16 -0
  18. package/dist/commands/builtins.js +223 -0
  19. package/dist/commands/registry.d.ts +48 -0
  20. package/dist/commands/registry.js +45 -0
  21. package/dist/commands/skills.d.ts +22 -0
  22. package/dist/commands/skills.js +625 -0
  23. package/dist/config/app-paths.d.ts +11 -0
  24. package/dist/config/app-paths.js +16 -0
  25. package/dist/config/env.d.ts +28 -0
  26. package/dist/config/env.js +92 -0
  27. package/dist/config/load.d.ts +48 -0
  28. package/dist/config/load.js +158 -0
  29. package/dist/config/migrate-legacy-state.d.ts +78 -0
  30. package/dist/config/migrate-legacy-state.js +181 -0
  31. package/dist/config/schema.d.ts +249 -0
  32. package/dist/config/schema.js +247 -0
  33. package/dist/config/store.d.ts +44 -0
  34. package/dist/config/store.js +126 -0
  35. package/dist/input/keymap.d.ts +20 -0
  36. package/dist/input/keymap.js +85 -0
  37. package/dist/session/persist.d.ts +24 -0
  38. package/dist/session/persist.js +51 -0
  39. package/dist/skills/archive.d.ts +41 -0
  40. package/dist/skills/archive.js +152 -0
  41. package/dist/skills/cli-commands.d.ts +52 -0
  42. package/dist/skills/cli-commands.js +502 -0
  43. package/dist/skills/fetch-source.d.ts +104 -0
  44. package/dist/skills/fetch-source.js +448 -0
  45. package/dist/skills/installer.d.ts +117 -0
  46. package/dist/skills/installer.js +414 -0
  47. package/dist/skills/lock.d.ts +75 -0
  48. package/dist/skills/lock.js +223 -0
  49. package/dist/skills/manifest.d.ts +47 -0
  50. package/dist/skills/manifest.js +95 -0
  51. package/dist/skills/node-host.d.ts +23 -0
  52. package/dist/skills/node-host.js +59 -0
  53. package/dist/skills/paths.d.ts +61 -0
  54. package/dist/skills/paths.js +107 -0
  55. package/dist/skills/service.d.ts +256 -0
  56. package/dist/skills/service.js +730 -0
  57. package/dist/skills/tools.d.ts +35 -0
  58. package/dist/skills/tools.js +164 -0
  59. package/dist/skills/updater.d.ts +101 -0
  60. package/dist/skills/updater.js +330 -0
  61. package/dist/skills/usage.d.ts +72 -0
  62. package/dist/skills/usage.js +216 -0
  63. package/dist/tools/bash-tool.d.ts +12 -0
  64. package/dist/tools/bash-tool.js +131 -0
  65. package/dist/tools/diff.d.ts +15 -0
  66. package/dist/tools/diff.js +50 -0
  67. package/dist/tools/fs-tools.d.ts +16 -0
  68. package/dist/tools/fs-tools.js +215 -0
  69. package/dist/tools/index.d.ts +93 -0
  70. package/dist/tools/index.js +157 -0
  71. package/dist/tools/search-tools.d.ts +11 -0
  72. package/dist/tools/search-tools.js +225 -0
  73. package/dist/ui/App.d.ts +24 -0
  74. package/dist/ui/App.js +522 -0
  75. package/dist/ui/AutocompletePopup.d.ts +21 -0
  76. package/dist/ui/AutocompletePopup.js +19 -0
  77. package/dist/ui/Composer.d.ts +40 -0
  78. package/dist/ui/Composer.js +61 -0
  79. package/dist/ui/Header.d.ts +30 -0
  80. package/dist/ui/Header.js +25 -0
  81. package/dist/ui/Logo.d.ts +50 -0
  82. package/dist/ui/Logo.js +85 -0
  83. package/dist/ui/Markdown.d.ts +23 -0
  84. package/dist/ui/Markdown.js +122 -0
  85. package/dist/ui/PromptInput.d.ts +81 -0
  86. package/dist/ui/PromptInput.js +383 -0
  87. package/dist/ui/SessionOpener.d.ts +30 -0
  88. package/dist/ui/SessionOpener.js +12 -0
  89. package/dist/ui/StatusBar.d.ts +39 -0
  90. package/dist/ui/StatusBar.js +43 -0
  91. package/dist/ui/ToastStack.d.ts +24 -0
  92. package/dist/ui/ToastStack.js +38 -0
  93. package/dist/ui/Transcript.d.ts +62 -0
  94. package/dist/ui/Transcript.js +128 -0
  95. package/dist/ui/capabilities.d.ts +25 -0
  96. package/dist/ui/capabilities.js +59 -0
  97. package/dist/ui/console-bridge.d.ts +19 -0
  98. package/dist/ui/console-bridge.js +52 -0
  99. package/dist/ui/density.d.ts +26 -0
  100. package/dist/ui/density.js +34 -0
  101. package/dist/ui/entries/AssistantEntry.d.ts +23 -0
  102. package/dist/ui/entries/AssistantEntry.js +13 -0
  103. package/dist/ui/entries/EntryFrame.d.ts +23 -0
  104. package/dist/ui/entries/EntryFrame.js +6 -0
  105. package/dist/ui/entries/ToolCard.d.ts +38 -0
  106. package/dist/ui/entries/ToolCard.js +72 -0
  107. package/dist/ui/entries/ToolPreview.d.ts +57 -0
  108. package/dist/ui/entries/ToolPreview.js +144 -0
  109. package/dist/ui/entries/UserEntry.d.ts +14 -0
  110. package/dist/ui/entries/UserEntry.js +5 -0
  111. package/dist/ui/exit-snapshot.d.ts +27 -0
  112. package/dist/ui/exit-snapshot.js +26 -0
  113. package/dist/ui/gauge.d.ts +24 -0
  114. package/dist/ui/gauge.js +36 -0
  115. package/dist/ui/glyphs.d.ts +87 -0
  116. package/dist/ui/glyphs.js +127 -0
  117. package/dist/ui/gradient.d.ts +11 -0
  118. package/dist/ui/gradient.js +50 -0
  119. package/dist/ui/layout/AppShell.d.ts +24 -0
  120. package/dist/ui/layout/AppShell.js +9 -0
  121. package/dist/ui/layout/Gutter.d.ts +67 -0
  122. package/dist/ui/layout/Gutter.js +42 -0
  123. package/dist/ui/layout/OverlayFrame.d.ts +62 -0
  124. package/dist/ui/layout/OverlayFrame.js +92 -0
  125. package/dist/ui/layout/ScrollViewport.d.ts +33 -0
  126. package/dist/ui/layout/ScrollViewport.js +71 -0
  127. package/dist/ui/layout/budget.d.ts +42 -0
  128. package/dist/ui/layout/budget.js +47 -0
  129. package/dist/ui/layout/frame.d.ts +58 -0
  130. package/dist/ui/layout/frame.js +75 -0
  131. package/dist/ui/layout/overlay-window.d.ts +43 -0
  132. package/dist/ui/layout/overlay-window.js +51 -0
  133. package/dist/ui/layout/scroll.d.ts +28 -0
  134. package/dist/ui/layout/scroll.js +76 -0
  135. package/dist/ui/layout/useTerminalSize.d.ts +13 -0
  136. package/dist/ui/layout/useTerminalSize.js +50 -0
  137. package/dist/ui/markdown-blocks.d.ts +62 -0
  138. package/dist/ui/markdown-blocks.js +120 -0
  139. package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
  140. package/dist/ui/overlays/ConfirmDialog.js +25 -0
  141. package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
  142. package/dist/ui/overlays/HelpOverlay.js +60 -0
  143. package/dist/ui/overlays/ModelPicker.d.ts +29 -0
  144. package/dist/ui/overlays/ModelPicker.js +29 -0
  145. package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
  146. package/dist/ui/overlays/SettingsScreen.js +106 -0
  147. package/dist/ui/palettes.d.ts +79 -0
  148. package/dist/ui/palettes.js +115 -0
  149. package/dist/ui/screen.d.ts +27 -0
  150. package/dist/ui/screen.js +57 -0
  151. package/dist/ui/theme.d.ts +78 -0
  152. package/dist/ui/theme.js +127 -0
  153. package/dist/ui/transcript-text.d.ts +27 -0
  154. package/dist/ui/transcript-text.js +110 -0
  155. package/package.json +73 -0
  156. package/skills/skill-creator/SKILL.md +96 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * AgentController — builds the core `Agent`, wires the built-in toolset, and
3
+ * exposes a small run/abort/steer surface plus the pre-flight validation that
4
+ * turns the most common failure (no API key) into a guided action instead of a
5
+ * blank screen (spec §3.3.1 / R2).
6
+ *
7
+ * The controller never inspects the resolved promise of `agent.prompt()` for
8
+ * success — that promise resolves even on failure. Success/failure is derived
9
+ * from the event stream by the reducer (TUI) and the headless writer.
10
+ */
11
+ import { ModelRegistry, type AgentEvent, type AgentTool, type Message, type ModelInfo, type ProviderRegistry, type ThinkingLevel } from '@aragon-agent/core';
12
+ import type { CliConfig, ThemeName } from '../config/schema.js';
13
+ import { type ConfirmRequest } from '../tools/index.js';
14
+ import { SkillService, type ApprovalGate } from '../skills/service.js';
15
+ import type { NoticeLevel } from './reducer.js';
16
+ /** Approval gate used whenever there is provably no human to ask (headless). */
17
+ export declare const DENY_ALL_APPROVAL: ApprovalGate;
18
+ export interface ControllerDeps {
19
+ /** Awaited before a mutating tool runs when `confirmTools` is enabled. */
20
+ confirm?: (req: ConfirmRequest) => Promise<boolean>;
21
+ /**
22
+ * Skill-install approval (§8.3.1 / D17). Distinct from `confirm` on purpose:
23
+ * that callback auto-approves when unattached, which is a sane default for
24
+ * `--confirm`-gated mutating tools and a silent security hole for skills.
25
+ * Defaults to deny-all, so forgetting to pass one fails CLOSED.
26
+ */
27
+ approval?: ApprovalGate;
28
+ /**
29
+ * CLI version stamped into `.aragon-skill.json` and the install User-Agent.
30
+ * Without it an agent-initiated install records `installer: "0.0.0"` while the
31
+ * same install through `/skills` or `aragon skills` records the real version —
32
+ * so provenance would depend on who started it, which is exactly the question
33
+ * `/skills info` exists to answer.
34
+ */
35
+ version?: string;
36
+ /** Called after the skill set changes so the UI can rebuild its commands. */
37
+ onSkillsChanged?: () => void;
38
+ /** Transcript notices from the skill subsystem. */
39
+ notify?: (level: NoticeLevel, text: string) => void;
40
+ }
41
+ export interface PreflightResult {
42
+ ok: boolean;
43
+ message?: string;
44
+ /** 'config' for a missing/invalid key or unusable provider. */
45
+ kind?: 'config';
46
+ }
47
+ export declare class AgentController {
48
+ private config;
49
+ private readonly agent;
50
+ private readonly providerRegistry;
51
+ private readonly modelRegistry;
52
+ private readonly tools;
53
+ private readonly skills;
54
+ private readonly skillsEnabled;
55
+ private onSkillsChanged?;
56
+ /** Mutable session cwd — tools resolve relative paths against this. */
57
+ private cwd;
58
+ constructor(config: CliConfig, deps?: ControllerDeps);
59
+ private resolveKey;
60
+ /**
61
+ * The tool names registered right now — LAZY, and the laziness is required.
62
+ *
63
+ * `this.skills.discover()` runs during construction, before `this.tools` is
64
+ * assigned, and the closure handed to `createBuiltinTools` is invoked later
65
+ * still. Reading `this.tools` eagerly would capture `undefined`; reading it
66
+ * through this getter yields `[]` in that window, which `computeToolPolicy`
67
+ * handles by design (I-G1: no registered tools means no ceiling, never an
68
+ * empty permitted set).
69
+ *
70
+ * The same ordering is why tool-name resolution must NOT move into
71
+ * `validateSkillFrontmatter()`: that runs inside `discover()`, when the tool
72
+ * list does not exist yet, and would report every skill as unresolvable.
73
+ * `doctor` carries that check instead (§5.7).
74
+ */
75
+ private toolNames;
76
+ private composeSystemPrompt;
77
+ private rebuildSystemPrompt;
78
+ /** Re-render the prompt from the current skill set and tell the UI. */
79
+ refreshSkills(): void;
80
+ getSkillService(): SkillService;
81
+ /** Let the UI attach its handler after construction (App mounts later). */
82
+ setOnSkillsChanged(handler: () => void): void;
83
+ /**
84
+ * Whether a usable API key exists for the given provider (default: active).
85
+ * Read-only CLI-layer helper for the Header/Welcome key-status dot (P1-1);
86
+ * does not touch `@aragon-agent/core`.
87
+ */
88
+ hasApiKey(provider?: string): boolean;
89
+ /**
90
+ * Set the color theme live. Mirrors the `setModel` / `setThinkingLevel`
91
+ * mutator pattern — a subsequent re-render re-reads `getConfig().theme` and
92
+ * re-memoizes the palette (P1-1). Does not touch `@aragon-agent/core`.
93
+ */
94
+ setTheme(name: ThemeName): void;
95
+ /**
96
+ * Advance the lifetime submit counter that drives the composer's progressive
97
+ * disclosure (§4.6). Lives here rather than in React state so incrementing it
98
+ * on every submit does not re-render the transcript.
99
+ */
100
+ setSubmitCount(n: number): void;
101
+ subscribe(listener: (event: AgentEvent) => void): () => void;
102
+ preflight(): PreflightResult;
103
+ /**
104
+ * Start a run. Fire-and-forget for the TUI (events drive the UI); returns the
105
+ * underlying promise so headless mode can await completion. `agent.prompt()`
106
+ * never rejects, so callers must not treat resolution as success.
107
+ */
108
+ prompt(text: string): Promise<void>;
109
+ abort(): void;
110
+ steer(text: string): void;
111
+ followUp(text: string): void;
112
+ isRunning(): boolean;
113
+ setModel(provider: string, model: string, baseUrl?: string): void;
114
+ setThinkingLevel(level: ThinkingLevel): void;
115
+ setMaxTokens(value: number | undefined): void;
116
+ setApiKey(provider: string, key: string): void;
117
+ setCwd(cwd: string): void;
118
+ getCwd(): string;
119
+ clearMessages(): void;
120
+ /** Read-only view of the live system prompt (regression tests, /debug). */
121
+ getSystemPrompt(): string;
122
+ replaceMessages(messages: Message[]): void;
123
+ getMessages(): Message[];
124
+ clearAllQueues(): void;
125
+ getConfig(): CliConfig;
126
+ getProviderRegistry(): ProviderRegistry;
127
+ getModelRegistry(): ModelRegistry;
128
+ /** Resolve `ModelInfo` for the active model, falling back to a runtime model. */
129
+ getModelInfo(): ModelInfo;
130
+ /** List usable tools (name + description) for the /tools command. */
131
+ listTools(): AgentTool[];
132
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * AgentController — builds the core `Agent`, wires the built-in toolset, and
3
+ * exposes a small run/abort/steer surface plus the pre-flight validation that
4
+ * turns the most common failure (no API key) into a guided action instead of a
5
+ * blank screen (spec §3.3.1 / R2).
6
+ *
7
+ * The controller never inspects the resolved promise of `agent.prompt()` for
8
+ * success — that promise resolves even on failure. Success/failure is derived
9
+ * from the event stream by the reducer (TUI) and the headless writer.
10
+ */
11
+ import { Agent, initProviders, ModelRegistry, } from '@aragon-agent/core';
12
+ import { isAdapterProvider } from '../config/schema.js';
13
+ import { makeGetApiKey } from '../config/load.js';
14
+ import { updatePersistedConfig } from '../config/store.js';
15
+ import { createBuiltinTools } from '../tools/index.js';
16
+ import { createNodeSkillHost } from '../skills/node-host.js';
17
+ import { SkillService } from '../skills/service.js';
18
+ import { createSkillTools } from '../skills/tools.js';
19
+ import { buildSystemPrompt } from './system-prompt.js';
20
+ /** Approval gate used whenever there is provably no human to ask (headless). */
21
+ export const DENY_ALL_APPROVAL = {
22
+ canPrompt: () => false,
23
+ request: async () => false,
24
+ };
25
+ export class AgentController {
26
+ config;
27
+ agent;
28
+ providerRegistry;
29
+ modelRegistry;
30
+ tools;
31
+ skills;
32
+ skillsEnabled;
33
+ onSkillsChanged;
34
+ /** Mutable session cwd — tools resolve relative paths against this. */
35
+ cwd;
36
+ constructor(config, deps = {}) {
37
+ this.config = config;
38
+ this.cwd = config.cwd;
39
+ this.providerRegistry = initProviders();
40
+ this.modelRegistry = new ModelRegistry(this.providerRegistry);
41
+ this.skillsEnabled = config.skills.enabled;
42
+ this.onSkillsChanged = deps.onSkillsChanged;
43
+ this.skills = new SkillService({
44
+ host: createNodeSkillHost(),
45
+ getCwd: () => this.cwd,
46
+ config: config.skills,
47
+ runtime: config.skillsRuntime,
48
+ // Fail-closed by default (D17): a missing gate must never mean "approved".
49
+ approval: deps.approval ?? DENY_ALL_APPROVAL,
50
+ onChange: () => this.refreshSkills(),
51
+ ...(deps.notify ? { notify: deps.notify } : {}),
52
+ persist: (patch) => {
53
+ try {
54
+ updatePersistedConfig({ skills: patch });
55
+ }
56
+ catch {
57
+ // Best-effort: a failed persist must not break the live session.
58
+ }
59
+ },
60
+ });
61
+ if (this.skillsEnabled) {
62
+ this.skills.discover();
63
+ this.skills.reportUnknownForcedSkills();
64
+ }
65
+ this.tools = createBuiltinTools({
66
+ getCwd: () => this.cwd,
67
+ confirmTools: config.confirmTools,
68
+ confirm: deps.confirm,
69
+ // `--no-skills` yields an empty array, so the tool list is byte-identical
70
+ // to the pre-Skills seven (invariant I-S1 / AC-10).
71
+ skillTools: this.skillsEnabled
72
+ ? createSkillTools(this.skills, deps.version, () => this.toolNames())
73
+ : [],
74
+ // Omitted entirely when skills are off, which is what leaves the seven
75
+ // built-ins unwrapped and AC-G17 provable by object identity.
76
+ ...(this.skillsEnabled
77
+ ? {
78
+ toolPolicy: () => this.skills.toolPolicyDecision(() => this.toolNames()),
79
+ onToolPolicyEvent: (e) => this.skills.reportToolPolicyEvent(e),
80
+ }
81
+ : {}),
82
+ });
83
+ const model = {
84
+ providerId: config.provider,
85
+ modelId: config.model,
86
+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
87
+ };
88
+ this.agent = new Agent({
89
+ systemPrompt: this.composeSystemPrompt(),
90
+ model,
91
+ tools: this.tools,
92
+ thinkingLevel: config.thinkingLevel,
93
+ providerRegistry: this.providerRegistry,
94
+ // Resolve the key dynamically so live edits (settings screen) take effect
95
+ // without rebuilding the Agent — the closure reads the current config.
96
+ getApiKey: (id) => this.resolveKey(id),
97
+ maxTokens: config.maxTokens,
98
+ timeouts: {
99
+ // Timeout invariant (R1): idle ≥ tool so long tool runs are not killed.
100
+ toolTimeout: config.toolTimeoutMs,
101
+ idleTimeout: config.idleTimeoutMs,
102
+ },
103
+ });
104
+ }
105
+ resolveKey(providerId) {
106
+ return makeGetApiKey(this.config)(providerId);
107
+ }
108
+ /**
109
+ * The tool names registered right now — LAZY, and the laziness is required.
110
+ *
111
+ * `this.skills.discover()` runs during construction, before `this.tools` is
112
+ * assigned, and the closure handed to `createBuiltinTools` is invoked later
113
+ * still. Reading `this.tools` eagerly would capture `undefined`; reading it
114
+ * through this getter yields `[]` in that window, which `computeToolPolicy`
115
+ * handles by design (I-G1: no registered tools means no ceiling, never an
116
+ * empty permitted set).
117
+ *
118
+ * The same ordering is why tool-name resolution must NOT move into
119
+ * `validateSkillFrontmatter()`: that runs inside `discover()`, when the tool
120
+ * list does not exist yet, and would report every skill as unresolvable.
121
+ * `doctor` carries that check instead (§5.7).
122
+ */
123
+ toolNames() {
124
+ return this.tools?.map((t) => t.name) ?? [];
125
+ }
126
+ // -----------------------------------------------------------------------
127
+ // System prompt — invariant I-S2 (§6.3.1 / P1-1 / C3)
128
+ //
129
+ // `agent.setSystemPrompt()` is called from EXACTLY ONE place in this class:
130
+ // `rebuildSystemPrompt()`. Before Skills existed, `setCwd()` built its own
131
+ // prompt inline; leaving it that way meant any rescan-then-setCwd ordering
132
+ // would drop `<available_skills>` with no error, no log, and no plausible
133
+ // connection between the user's action (`/cwd ..`) and the symptom (the model
134
+ // suddenly denies having any skills). Structure, not call ordering, is what
135
+ // rules that out — so if you add a third caller, route it through here.
136
+ // -----------------------------------------------------------------------
137
+ composeSystemPrompt() {
138
+ return buildSystemPrompt({
139
+ cwd: this.cwd,
140
+ tools: this.tools,
141
+ skillsBlock: this.skillsEnabled ? this.skills.catalogBlock() + this.skills.alwaysBlock() : '',
142
+ });
143
+ }
144
+ rebuildSystemPrompt() {
145
+ this.agent.setSystemPrompt(this.composeSystemPrompt());
146
+ }
147
+ /** Re-render the prompt from the current skill set and tell the UI. */
148
+ refreshSkills() {
149
+ this.rebuildSystemPrompt();
150
+ this.onSkillsChanged?.();
151
+ }
152
+ getSkillService() {
153
+ return this.skills;
154
+ }
155
+ /** Let the UI attach its handler after construction (App mounts later). */
156
+ setOnSkillsChanged(handler) {
157
+ this.onSkillsChanged = handler;
158
+ }
159
+ /**
160
+ * Whether a usable API key exists for the given provider (default: active).
161
+ * Read-only CLI-layer helper for the Header/Welcome key-status dot (P1-1);
162
+ * does not touch `@aragon-agent/core`.
163
+ */
164
+ hasApiKey(provider) {
165
+ const key = this.resolveKey(provider ?? this.config.provider);
166
+ return !!key && key.trim().length > 0;
167
+ }
168
+ /**
169
+ * Set the color theme live. Mirrors the `setModel` / `setThinkingLevel`
170
+ * mutator pattern — a subsequent re-render re-reads `getConfig().theme` and
171
+ * re-memoizes the palette (P1-1). Does not touch `@aragon-agent/core`.
172
+ */
173
+ setTheme(name) {
174
+ this.config = { ...this.config, theme: name };
175
+ }
176
+ /**
177
+ * Advance the lifetime submit counter that drives the composer's progressive
178
+ * disclosure (§4.6). Lives here rather than in React state so incrementing it
179
+ * on every submit does not re-render the transcript.
180
+ */
181
+ setSubmitCount(n) {
182
+ this.config = { ...this.config, submitCount: n };
183
+ }
184
+ // -----------------------------------------------------------------------
185
+ // Event subscription (delegates to the core Agent)
186
+ // -----------------------------------------------------------------------
187
+ subscribe(listener) {
188
+ return this.agent.subscribe(listener);
189
+ }
190
+ // -----------------------------------------------------------------------
191
+ // Pre-flight (R2): validate BEFORE entering the loop.
192
+ // -----------------------------------------------------------------------
193
+ preflight() {
194
+ const provider = this.config.provider;
195
+ if (!isAdapterProvider(provider)) {
196
+ return {
197
+ ok: false,
198
+ kind: 'config',
199
+ message: `Provider "${provider}" has no adapter. Choose one of anthropic, openai, or google ` +
200
+ '(open /settings or set --provider).',
201
+ };
202
+ }
203
+ const key = makeGetApiKey(this.config)(provider);
204
+ if (!key || key.trim().length === 0) {
205
+ const envVar = provider === 'anthropic'
206
+ ? 'ANTHROPIC_API_KEY'
207
+ : provider === 'openai'
208
+ ? 'OPENAI_API_KEY'
209
+ : 'GOOGLE_API_KEY';
210
+ return {
211
+ ok: false,
212
+ kind: 'config',
213
+ message: `No API key for "${provider}" - open /settings or set ${envVar}.`,
214
+ };
215
+ }
216
+ return { ok: true };
217
+ }
218
+ // -----------------------------------------------------------------------
219
+ // Run lifecycle
220
+ // -----------------------------------------------------------------------
221
+ /**
222
+ * Start a run. Fire-and-forget for the TUI (events drive the UI); returns the
223
+ * underlying promise so headless mode can await completion. `agent.prompt()`
224
+ * never rejects, so callers must not treat resolution as success.
225
+ */
226
+ prompt(text) {
227
+ // A new user message is a new intent, so the previous turn's tool ceiling
228
+ // expires here and anything a slash command queued is promoted (D-G1 / D-G2).
229
+ // This and `steer()` below are the ONLY two places a user message enters the
230
+ // engine, which is what makes a per-turn scope implementable at all (FG9).
231
+ if (this.skillsEnabled)
232
+ this.skills.beginUserTurn();
233
+ return this.agent.prompt(text);
234
+ }
235
+ abort() {
236
+ this.agent.abort();
237
+ }
238
+ steer(text) {
239
+ // Mid-run interjection: promote the queue, keep what is already in force
240
+ // (D-G3). Clearing here would make "ask a follow-up" the way around the
241
+ // ceiling; skipping the promotion would miss slash commands, which the TUI
242
+ // routes through `steer` whenever the agent happens to be running.
243
+ if (this.skillsEnabled)
244
+ this.skills.absorbPendingFrames();
245
+ this.agent.steer(text);
246
+ }
247
+ followUp(text) {
248
+ this.agent.followUp(text);
249
+ }
250
+ isRunning() {
251
+ return this.agent.state.isRunning;
252
+ }
253
+ // -----------------------------------------------------------------------
254
+ // Mutators
255
+ // -----------------------------------------------------------------------
256
+ setModel(provider, model, baseUrl) {
257
+ this.config = { ...this.config, provider, model, baseUrl };
258
+ this.agent.setModel({ providerId: provider, modelId: model, ...(baseUrl ? { baseUrl } : {}) });
259
+ }
260
+ setThinkingLevel(level) {
261
+ this.config = { ...this.config, thinkingLevel: level };
262
+ this.agent.setThinkingLevel(level);
263
+ }
264
+ setMaxTokens(value) {
265
+ this.config = { ...this.config, maxTokens: value };
266
+ this.agent.setMaxTokens(value);
267
+ }
268
+ setApiKey(provider, key) {
269
+ // The Agent resolves keys via `resolveKey(this.config)` at call time, so
270
+ // updating the config here is sufficient — no Agent rebuild needed.
271
+ this.config = {
272
+ ...this.config,
273
+ apiKeys: { ...this.config.apiKeys, [provider]: key },
274
+ };
275
+ }
276
+ setCwd(cwd) {
277
+ this.cwd = cwd;
278
+ this.config = { ...this.config, cwd };
279
+ // The project scope moved with the cwd, so rescan BEFORE rebuilding — then
280
+ // let the single prompt entry point emit a prompt that reflects both the new
281
+ // directory and the new skill set (I-S2 / AC-17).
282
+ if (this.skillsEnabled)
283
+ this.skills.discover();
284
+ this.rebuildSystemPrompt();
285
+ this.onSkillsChanged?.();
286
+ }
287
+ getCwd() {
288
+ return this.cwd;
289
+ }
290
+ clearMessages() {
291
+ this.agent.clearMessages();
292
+ // A new conversation has no loaded skills, so the "already loaded earlier
293
+ // in this conversation" hint would otherwise start lying (P2-9).
294
+ this.skills.getRegistry().clearActive();
295
+ }
296
+ /** Read-only view of the live system prompt (regression tests, /debug). */
297
+ getSystemPrompt() {
298
+ return this.agent.state.systemPrompt;
299
+ }
300
+ replaceMessages(messages) {
301
+ this.agent.replaceMessages(messages);
302
+ }
303
+ getMessages() {
304
+ return this.agent.state.messages;
305
+ }
306
+ clearAllQueues() {
307
+ this.agent.clearAllQueues();
308
+ }
309
+ // -----------------------------------------------------------------------
310
+ // Model / provider info
311
+ // -----------------------------------------------------------------------
312
+ getConfig() {
313
+ return this.config;
314
+ }
315
+ getProviderRegistry() {
316
+ return this.providerRegistry;
317
+ }
318
+ getModelRegistry() {
319
+ return this.modelRegistry;
320
+ }
321
+ /** Resolve `ModelInfo` for the active model, falling back to a runtime model. */
322
+ getModelInfo() {
323
+ const found = this.modelRegistry.getModel(this.config.provider, this.config.model);
324
+ if (found)
325
+ return found;
326
+ return this.modelRegistry.buildRuntimeModel(this.config.provider, this.config.model);
327
+ }
328
+ /** List usable tools (name + description) for the /tools command. */
329
+ listTools() {
330
+ return this.tools;
331
+ }
332
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * One-shot / print mode (spec §3.7). No Ink: stream `text_delta` to stdout,
3
+ * compact tool lines to stderr, then a usage footer. Exit-code correctness is
4
+ * derived from the event stream (R2), not from the resolved promise:
5
+ * 0 = success, 1 = agent error (incl. a silently-swallowed throw), 2 = config.
6
+ */
7
+ import type { AgentEvent, ModelInfo } from '@aragon-agent/core';
8
+ import type { PreflightResult } from './controller.js';
9
+ /** The minimal controller surface headless mode needs (real or stubbed). */
10
+ export interface HeadlessController {
11
+ preflight(): PreflightResult;
12
+ subscribe(listener: (event: AgentEvent) => void): () => void;
13
+ prompt(text: string): Promise<void>;
14
+ getModelInfo(): ModelInfo;
15
+ }
16
+ export interface HeadlessOptions {
17
+ quiet?: boolean;
18
+ stdout?: NodeJS.WritableStream;
19
+ stderr?: NodeJS.WritableStream;
20
+ }
21
+ /** Run a single prompt headlessly and resolve with the process exit code. */
22
+ export declare function runHeadless(controller: HeadlessController, prompt: string, options?: HeadlessOptions): Promise<number>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * One-shot / print mode (spec §3.7). No Ink: stream `text_delta` to stdout,
3
+ * compact tool lines to stderr, then a usage footer. Exit-code correctness is
4
+ * derived from the event stream (R2), not from the resolved promise:
5
+ * 0 = success, 1 = agent error (incl. a silently-swallowed throw), 2 = config.
6
+ */
7
+ import { formatCost, formatTokens, computeCost } from './usage.js';
8
+ import { formatStreamError } from './reducer.js';
9
+ /** Run a single prompt headlessly and resolve with the process exit code. */
10
+ export async function runHeadless(controller, prompt, options = {}) {
11
+ const out = options.stdout ?? process.stdout;
12
+ const err = options.stderr ?? process.stderr;
13
+ const quiet = options.quiet ?? false;
14
+ const pre = controller.preflight();
15
+ if (!pre.ok) {
16
+ err.write(`${pre.message ?? 'Configuration error.'}\n`);
17
+ return 2;
18
+ }
19
+ let errored = false;
20
+ let sawTurnEnd = false;
21
+ let wroteText = false;
22
+ const total = { inputTokens: 0, outputTokens: 0 };
23
+ const unsubscribe = controller.subscribe((event) => {
24
+ switch (event.type) {
25
+ case 'message_update': {
26
+ const se = event.streamEvent;
27
+ if (se.type === 'text_delta') {
28
+ out.write(se.delta);
29
+ wroteText = true;
30
+ }
31
+ else if (se.type === 'error') {
32
+ errored = true;
33
+ err.write(`\n${formatStreamError(se.error)}\n`);
34
+ }
35
+ break;
36
+ }
37
+ case 'tool_execution_start':
38
+ if (!quiet)
39
+ err.write(`\n▸ ${event.toolName}`);
40
+ break;
41
+ case 'tool_execution_end':
42
+ if (!quiet) {
43
+ err.write(` (${event.duration}ms)${event.isError ? ' [error]' : ''}\n`);
44
+ }
45
+ break;
46
+ case 'turn_end':
47
+ sawTurnEnd = true;
48
+ total.inputTokens += event.usage.inputTokens;
49
+ total.outputTokens += event.usage.outputTokens;
50
+ break;
51
+ case 'agent_end':
52
+ // A swallowed throw (missing key / empty stream) produces no turn_end.
53
+ if (!sawTurnEnd && !errored)
54
+ errored = true;
55
+ break;
56
+ default:
57
+ break;
58
+ }
59
+ });
60
+ try {
61
+ await controller.prompt(prompt);
62
+ }
63
+ finally {
64
+ unsubscribe();
65
+ }
66
+ // Ensure a trailing newline so piped output is well-formed.
67
+ if (wroteText)
68
+ out.write('\n');
69
+ if (!quiet) {
70
+ const cost = computeCost(total, controller.getModelInfo().cost);
71
+ err.write(`\n[usage] in ${formatTokens(total.inputTokens)} · out ${formatTokens(total.outputTokens)} · ${formatCost(cost)}\n`);
72
+ }
73
+ return errored ? 1 : 0;
74
+ }