@semalt-ai/code 1.8.5 → 1.20.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 (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. package/path +0 -1
package/lib/sdk.js ADDED
@@ -0,0 +1,328 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Embedding SDK — the STABLE public facade (Task 5.2)
5
+ // ---------------------------------------------------------------------------
6
+ //
7
+ // `createAgent(options)` is the supported, semver-stable way to embed the agent
8
+ // in another program. It encapsulates assembly — api client, tool registry,
9
+ // permission manager, sandbox, config — behind a small object:
10
+ //
11
+ // const { createAgent } = require('@semalt-ai/code');
12
+ // const agent = createAgent({ apiBase, apiKey, model });
13
+ // const res = await agent.run('Summarise README.md');
14
+ // await agent.close();
15
+ //
16
+ // The shape returned by `run()` is the same structured envelope headless mode
17
+ // produces: { result, toolCalls, usage, cost, stopReason, verifyStatus }
18
+ // (plus `messages` for multi-turn continuation).
19
+ //
20
+ // SECURITY — defaults safe (constraint 2). There is no TTY in embedded use, so
21
+ // the facade takes a programmatic permission policy:
22
+ // * `approve(call) => boolean|Promise<boolean>` — an async approver, OR
23
+ // * `rules: [...]` — preset allow/deny/ask rules (the Task 4.1 engine).
24
+ // With NEITHER provided the default is to REFUSE every mutating/effectful tool
25
+ // (mirroring non-TTY), never auto-approve. The OS sandbox and the destructive-
26
+ // command deny-list stay ON; disabling them is an explicit, documented opt-in
27
+ // (`sandbox: { mode: 'off' }`, and the process-global
28
+ // `--dangerously-skip-permissions`), never a side effect of embedding.
29
+ //
30
+ // LIFECYCLE (constraint 3). `createAgent` may open resources (MCP servers).
31
+ // Hosts MUST call `close()` to release them.
32
+ //
33
+ // The unstable building blocks (runAgentLoop, createApiClient, the registries)
34
+ // live behind the separate `@semalt-ai/code/internals` subpath — see
35
+ // lib/internals.js.
36
+
37
+ const { EventEmitter } = require('events');
38
+
39
+ const { DEFAULT_CONFIG } = require('./constants');
40
+ const { normalizeConfig, readUserConfig, loadProjectConfig, resolveMaxIterations, isNativeToolsActive } = require('./config');
41
+ const { loadRuleLayers } = require('./permission-rules');
42
+ const ui = require('./ui');
43
+ const { createPermissionManager } = require('./permissions');
44
+ const { createToolExecutor, extractToolCalls, setUIActive, isUIActive } = require('./tools');
45
+ const { createApiClient } = require('./api');
46
+ const { createAgentRunner } = require('./agent');
47
+ const { createHeadlessSink } = require('./headless');
48
+
49
+ const TIER_NAMES = new Set(['fs', 'exec', 'net', 'sys']);
50
+
51
+ // Resolve the requested permission tiers into the allowedTiers array the
52
+ // PermissionManager expects. Accepts 'all', a single tier string, or an array.
53
+ function resolveTiers(allow) {
54
+ if (!allow) return [];
55
+ if (allow === 'all') return ['fs', 'exec', 'net', 'sys'];
56
+ const arr = Array.isArray(allow) ? allow : [allow];
57
+ const out = [];
58
+ for (const a of arr) {
59
+ if (a === 'all') return ['fs', 'exec', 'net', 'sys'];
60
+ if (TIER_NAMES.has(a)) out.push(a);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ // Build the user-layer rule set from a host-supplied `rules` option. Host rules
66
+ // are TRUSTED (the embedding program authored them), so they map to the USER
67
+ // layer — they may widen, unlike project rules. A project `.semalt/config.json`
68
+ // (loaded when `loadProjectRules` is on) can still only NARROW, exactly as the
69
+ // CLI enforces.
70
+ function buildRuleLayers({ rules, cwd, loadProjectRules }) {
71
+ let userRules = [];
72
+ if (Array.isArray(rules)) userRules = rules;
73
+ else if (rules && Array.isArray(rules.user)) userRules = rules.user;
74
+ const projectRules = (rules && Array.isArray(rules.project)) ? rules.project : [];
75
+ const userCfg = { permissions: { rules: userRules } };
76
+ let projectCfg = { permissions: { rules: projectRules } };
77
+ if (loadProjectRules) {
78
+ const fromDisk = loadProjectConfig(cwd) || {};
79
+ const diskRules = (fromDisk.permissions && Array.isArray(fromDisk.permissions.rules)) ? fromDisk.permissions.rules : [];
80
+ projectCfg = { permissions: { rules: projectRules.concat(diskRules) } };
81
+ }
82
+ return loadRuleLayers(userCfg, projectCfg, () => {});
83
+ }
84
+
85
+ // Assemble a per-instance, normalized config object. By default the host's
86
+ // ~/.semalt-ai/config.json is NOT read (a server embedding the SDK wants
87
+ // isolation, not the operator's personal defaults); pass `loadUserConfig: true`
88
+ // to layer it in. Explicit options always win.
89
+ function buildConfig(options) {
90
+ const {
91
+ apiBase, apiKey, dashboardUrl, model, contextLength,
92
+ config: configOverride = {},
93
+ loadUserConfig = false,
94
+ sandbox,
95
+ maxIterations,
96
+ } = options;
97
+
98
+ const base = loadUserConfig ? readUserConfig() : {};
99
+ const merged = { ...DEFAULT_CONFIG, ...base, ...configOverride };
100
+ if (apiBase != null) merged.api_base = apiBase;
101
+ if (apiKey != null) merged.api_key = apiKey;
102
+ if (dashboardUrl != null) merged.dashboard_url = dashboardUrl;
103
+ if (model != null) merged.default_model = model;
104
+ if (contextLength != null) merged.context_length = contextLength;
105
+ if (sandbox && typeof sandbox === 'object') merged.sandbox = { ...(merged.sandbox || {}), ...sandbox };
106
+ if (maxIterations != null) merged.max_iterations = maxIterations;
107
+ return normalizeConfig(merged);
108
+ }
109
+
110
+ function createAgent(options = {}) {
111
+ const cwd = options.cwd || process.cwd();
112
+
113
+ // Per-instance config behind a closure (NOT the module-global in index.js), so
114
+ // two createAgent() instances never share config state.
115
+ let config = buildConfig(options);
116
+ const getConfig = () => config;
117
+ // setConfig stays in-memory only — the SDK never writes the operator's
118
+ // config.json (e.g. a learned context-length is kept for this instance alone).
119
+ const setConfig = (next) => { config = normalizeConfig({ ...config, ...next }); };
120
+
121
+ const emitter = new EventEmitter();
122
+ // Don't let a missing/throwing 'error' listener crash the host process — the
123
+ // run result already carries failures; events are advisory.
124
+ emitter.on('error', () => {});
125
+
126
+ // ── Permission perimeter (defaults safe) ────────────────────────────────
127
+ const allowedTiers = resolveTiers(options.allow);
128
+ const ruleLayers = buildRuleLayers({
129
+ rules: options.rules,
130
+ cwd,
131
+ loadProjectRules: options.loadProjectRules === true,
132
+ });
133
+ // The host-supplied async approver. We ALWAYS install one (the host's, or a
134
+ // refuse-by-default fallback) so the embedded gate decision is deterministic
135
+ // and never falls through to the interactive TTY prompt — even if the host
136
+ // process happens to have a TTY. The approver is consulted only AFTER an allow
137
+ // tier / allow rule has had its say, so with no policy at all it refuses every
138
+ // mutating tool: the safe embedded default.
139
+ const approver = typeof options.approve === 'function'
140
+ ? (call) => options.approve(call)
141
+ : () => false;
142
+ // dangerouslySkipPermissions is the programmatic equivalent of the human-only
143
+ // CLI flag: it auto-approves the GATE. NOTE it does NOT disable the deny-list
144
+ // / secret-read / config-write guards — those read the process argv once at
145
+ // module load (see lib/tools.js); turning them off requires launching the host
146
+ // process with --dangerously-skip-permissions. This asymmetry is documented in
147
+ // the README ("Multi-instance & process-global state").
148
+ const skipPermissions = options.dangerouslySkipPermissions === true;
149
+
150
+ const permissionManager = createPermissionManager(ui, {
151
+ allowedTiers,
152
+ readonly: options.readonly === true,
153
+ skipPermissions,
154
+ rules: ruleLayers,
155
+ cwd,
156
+ approver,
157
+ // Embedded: keep stdout byte-clean — the host gets denials in the result.
158
+ quiet: true,
159
+ });
160
+
161
+ // ── OS-sandbox fallback approver ─────────────────────────────────────────
162
+ // When the kernel sandbox is unavailable in `auto` mode, this decides whether
163
+ // to run a command unsandboxed. It lives here (NOT anywhere the model can
164
+ // reach). Default: refuse (return false) — never a silent unsandboxed run.
165
+ // A host opts into running unsandboxed by passing `onUnsandboxed`.
166
+ const onUnsandboxed = typeof options.onUnsandboxed === 'function'
167
+ ? options.onUnsandboxed
168
+ : async () => false;
169
+
170
+ const apiClient = createApiClient({ getConfig, saveConfig: setConfig, ui });
171
+
172
+ const { runAgentLoop } = createAgentRunner({
173
+ chatStream: apiClient.chatStream,
174
+ extractToolCalls: (reply, opts = {}) => extractToolCalls(reply, {
175
+ repairMalformedXml: !!getConfig().repair_malformed_tool_xml,
176
+ ...opts,
177
+ }),
178
+ ...createToolExecutor(permissionManager, ui, getConfig, {
179
+ onUnsandboxed,
180
+ // Web-fetch secondary summarizer (Task W.1) — same chatComplete-backed
181
+ // call the CLI wires; http_get summarizes extracted content in isolation.
182
+ webChat: (messages, opts) => apiClient.chatComplete(messages, opts),
183
+ // Web search (Task W.2b) — the web_search tool calls the backend
184
+ // /api/search via dashboardSearch; compact snippets, not full pages.
185
+ webSearch: (query, opts) => apiClient.dashboardSearch(query, opts),
186
+ }),
187
+ permissionManager,
188
+ ui,
189
+ getConfig,
190
+ onUnsandboxed,
191
+ });
192
+
193
+ // ── MCP (optional, lazily connected) ─────────────────────────────────────
194
+ // Connected on first run() when config.mcp.servers is non-empty, so close()
195
+ // has something to tear down. NOTE: the dynamic tool registry MCP tools land
196
+ // in is PROCESS-GLOBAL (lib/tool_registry.js _dynamic) — concurrent instances
197
+ // with different MCP servers would see each other's tools. Documented limit.
198
+ let mcpManager = null;
199
+ let mcpConnected = false;
200
+ async function ensureMcp() {
201
+ const servers = (getConfig().mcp && getConfig().mcp.servers) || {};
202
+ if (!Object.keys(servers).length) return;
203
+ if (!mcpManager) {
204
+ const { createMcpManager } = require('./mcp/client');
205
+ mcpManager = createMcpManager({ getConfig, logger: (m) => emitter.emit('warning', m) });
206
+ }
207
+ if (!mcpConnected) {
208
+ await mcpManager.connectAll();
209
+ mcpConnected = true;
210
+ }
211
+ }
212
+
213
+ let closed = false;
214
+ let memoryWarned = false;
215
+
216
+ async function run(prompt, runOpts = {}) {
217
+ if (closed) throw new Error('createAgent: this agent has been close()d');
218
+ await ensureMcp();
219
+
220
+ // Fail-loud (memory truncation): the agent's system prompt loads project
221
+ // memory (AGENTS.md/CLAUDE.md) from disk; if a file was cut at the cap, warn
222
+ // the host once via the 'warning' event (same channel as MCP warnings) —
223
+ // user-facing, never added to the model prompt. Once per agent, not per run.
224
+ if (!memoryWarned) {
225
+ memoryWarned = true;
226
+ try {
227
+ const { loadProjectMemory, memoryTruncationWarnings } = require('./memory');
228
+ for (const w of memoryTruncationWarnings(loadProjectMemory())) emitter.emit('warning', w);
229
+ } catch { /* best-effort; never block a run */ }
230
+ }
231
+
232
+ const model = runOpts.model || getConfig().default_model;
233
+ const messages = Array.isArray(runOpts.messages)
234
+ ? runOpts.messages.slice()
235
+ : [{ role: 'user', content: String(prompt == null ? '' : prompt) }];
236
+
237
+ // Multimodal image input (Task 5.4). `images` accepts file paths or
238
+ // already-encoded { media_type, data } records; each is read through the
239
+ // size + path guards and attached to the latest user turn. A bad image
240
+ // throws here (the host gets a clear error), never a silent drop.
241
+ if (runOpts.images && runOpts.images.length) {
242
+ const { resolveImageInputs, attachImagesToLastUser } = require('./images');
243
+ const { isPathSafe } = require('./tools');
244
+ const imgs = resolveImageInputs(runOpts.images, { maxBytes: getConfig().image_max_bytes, isPathSafe });
245
+ attachImagesToLastUser(messages, imgs);
246
+ }
247
+ const maxIter = runOpts.maxIterations != null
248
+ ? resolveMaxIterations(runOpts.maxIterations)
249
+ : resolveMaxIterations(getConfig().max_iterations);
250
+ const tokenLimit = runOpts.tokenLimit != null ? runOpts.tokenLimit : (getConfig().context_length || null);
251
+
252
+ // Reuse the headless json sink to build the documented result envelope
253
+ // (result / toolCalls / usage / cost / stopReason / verifyStatus). We
254
+ // capture the object instead of writing a JSON line to stdout.
255
+ let envelope = null;
256
+ const sink = createHeadlessSink('json', (obj) => { envelope = obj; }, {
257
+ model,
258
+ priceOverrides: getConfig().pricing || {},
259
+ });
260
+
261
+ // Forward streaming activity to the event emitter (advisory; the result is
262
+ // authoritative). Merge AFTER the sink's machine callbacks so both run.
263
+ const eventCallbacks = {
264
+ onToken: (t) => emitter.emit('token', t),
265
+ onAssistantMessage: (m) => emitter.emit('assistant', m),
266
+ onToolStart: (ctx) => emitter.emit('tool-start', ctx),
267
+ onToolEnd: (tag, resultStr, ms, meta) => emitter.emit('tool', { tag, result: resultStr, ms, meta }),
268
+ onError: (e) => emitter.emit('error', e),
269
+ };
270
+ const callbacks = {};
271
+ for (const key of new Set([...Object.keys(eventCallbacks), ...Object.keys(sink.callbacks)])) {
272
+ const a = eventCallbacks[key];
273
+ const b = sink.callbacks[key];
274
+ callbacks[key] = (...args) => { if (a) a(...args); if (b) b(...args); };
275
+ }
276
+
277
+ // Keep the host's stdout byte-clean: suppress the tool ✓/✗ lines and the
278
+ // write/append permission diff for the duration (the same flag headless
279
+ // machine modes use). NOTE this flag is process-global (lib/tools.js) — two
280
+ // instances running CONCURRENTLY share it; documented under multi-instance.
281
+ const prevUIActive = isUIActive();
282
+ setUIActive(true);
283
+ let res;
284
+ try {
285
+ res = await runAgentLoop(messages, model, maxIter, tokenLimit, {
286
+ callbacks,
287
+ systemPrompt: runOpts.systemPrompt != null ? runOpts.systemPrompt : null,
288
+ systemPromptMode: getConfig().system_prompt_mode || 'system_role',
289
+ planMode: !!runOpts.planMode,
290
+ noVerify: !!runOpts.noVerify,
291
+ debug: false,
292
+ });
293
+ } finally {
294
+ setUIActive(prevUIActive);
295
+ }
296
+ sink.finalize(res);
297
+ emitter.emit('done', envelope);
298
+ // Attach the running message history so a host can continue the conversation
299
+ // by passing { messages } back into a subsequent run().
300
+ return { ...envelope, messages: res.messages };
301
+ }
302
+
303
+ function on(event, cb) { emitter.on(event, cb); return handle; }
304
+ function off(event, cb) { emitter.off(event, cb); return handle; }
305
+
306
+ async function close() {
307
+ if (closed) return;
308
+ closed = true;
309
+ if (mcpManager) {
310
+ try { await mcpManager.shutdown(); } catch { /* best-effort */ }
311
+ }
312
+ emitter.removeAllListeners();
313
+ }
314
+
315
+ const handle = {
316
+ run,
317
+ on,
318
+ off,
319
+ close,
320
+ // Read-only introspection — handy for hosts; not a stability-critical surface.
321
+ getConfig,
322
+ get cwd() { return cwd; },
323
+ get closed() { return closed; },
324
+ };
325
+ return handle;
326
+ }
327
+
328
+ module.exports = { createAgent };
package/lib/secrets.js ADDED
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // API-key sourcing — env var → OS keychain → config file
5
+ // ---------------------------------------------------------------------------
6
+ //
7
+ // Precedence (highest first):
8
+ // 1. SEMALT_API_KEY environment variable
9
+ // 2. OS keychain (macOS Keychain / Linux libsecret / Windows Credential Mgr)
10
+ // 3. api_key in ~/.semalt-ai/config.json (plaintext fallback)
11
+ //
12
+ // Keys sourced from (1) or (2) are NEVER written back to config.json and are
13
+ // reported as redacted by configShow — only the source label is surfaced.
14
+ //
15
+ // Keychain access shells out to the platform's native tool (no npm deps). If
16
+ // the tool is missing or fails, we degrade gracefully and fall through to the
17
+ // next source rather than crashing.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const { spawnSync } = require('child_process');
21
+
22
+ const ENV_VAR = 'SEMALT_API_KEY';
23
+ const KEYCHAIN_SERVICE = 'semalt-code';
24
+ const KEYCHAIN_ACCOUNT = 'api_key';
25
+
26
+ // Process-lifetime cache so we don't re-shell to the keychain on every request.
27
+ // undefined = not yet resolved. { source, key } once resolved.
28
+ let _cache;
29
+
30
+ function _run(cmd, args, input) {
31
+ try {
32
+ const res = spawnSync(cmd, args, {
33
+ input: input === undefined ? undefined : input,
34
+ encoding: 'utf8',
35
+ timeout: 5000,
36
+ windowsHide: true,
37
+ });
38
+ if (!res || res.error) return null;
39
+ if (res.status !== 0) return null;
40
+ return typeof res.stdout === 'string' ? res.stdout : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ // Strip only a single trailing CR/LF that the keychain tool appends — never
47
+ // trim interior or leading characters, which could be meaningful in a key.
48
+ function _stripTrailingNewline(s) {
49
+ return typeof s === 'string' ? s.replace(/\r?\n$/, '') : s;
50
+ }
51
+
52
+ // Generic keychain read for an arbitrary (service, account). Returns the stored
53
+ // secret string or null. The platform branches mirror the API-key path; the
54
+ // MCP OAuth token store (lib/mcp/oauth.js) reuses this so OAuth tokens land in
55
+ // the OS keychain, never in plaintext config.
56
+ function keychainGetItem(service, account) {
57
+ if (!service || !account) return null;
58
+ const platform = process.platform;
59
+ if (platform === 'darwin') {
60
+ const out = _run('security', [
61
+ 'find-generic-password', '-s', service, '-a', account, '-w',
62
+ ]);
63
+ const v = _stripTrailingNewline(out);
64
+ return v && v.length ? v : null;
65
+ }
66
+ if (platform === 'linux') {
67
+ const out = _run('secret-tool', [
68
+ 'lookup', 'service', service, 'account', account,
69
+ ]);
70
+ const v = _stripTrailingNewline(out);
71
+ return v && v.length ? v : null;
72
+ }
73
+ if (platform === 'win32') {
74
+ const ps =
75
+ `try {` +
76
+ `[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];` +
77
+ `$v = New-Object Windows.Security.Credentials.PasswordVault;` +
78
+ `$c = $v.Retrieve('${service}','${account}');` +
79
+ `$c.RetrievePassword(); Write-Output $c.Password` +
80
+ `} catch { exit 1 }`;
81
+ const out = _run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
82
+ const v = _stripTrailingNewline(out);
83
+ return v && v.trim().length ? v.trim() : null;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ // Generic keychain write for an arbitrary (service, account). Returns true on
89
+ // success, false if the platform tool is unavailable or the store failed.
90
+ function keychainSetItem(service, account, value, label) {
91
+ if (!service || !account || typeof value !== 'string' || !value) return false;
92
+ const platform = process.platform;
93
+ if (platform === 'darwin') {
94
+ const out = _run('security', [
95
+ 'add-generic-password', '-U', '-s', service, '-a', account, '-w', value,
96
+ ]);
97
+ return out !== null;
98
+ }
99
+ if (platform === 'linux') {
100
+ const out = _run('secret-tool', [
101
+ 'store', '--label', label || `${service} ${account}`,
102
+ 'service', service, 'account', account,
103
+ ], value);
104
+ return out !== null;
105
+ }
106
+ if (platform === 'win32') {
107
+ const escaped = value.replace(/'/g, "''");
108
+ const ps =
109
+ `try {` +
110
+ `[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];` +
111
+ `$v = New-Object Windows.Security.Credentials.PasswordVault;` +
112
+ `$cred = New-Object Windows.Security.Credentials.PasswordCredential('${service}','${account}','${escaped}');` +
113
+ `$v.Add($cred)` +
114
+ `} catch { exit 1 }`;
115
+ const out = _run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
116
+ return out !== null;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ // Generic keychain delete for an arbitrary (service, account). Best-effort;
122
+ // returns true if the platform tool reported success.
123
+ function keychainDeleteItem(service, account) {
124
+ if (!service || !account) return false;
125
+ const platform = process.platform;
126
+ if (platform === 'darwin') {
127
+ const out = _run('security', ['delete-generic-password', '-s', service, '-a', account]);
128
+ return out !== null;
129
+ }
130
+ if (platform === 'linux') {
131
+ const out = _run('secret-tool', ['clear', 'service', service, 'account', account]);
132
+ return out !== null;
133
+ }
134
+ if (platform === 'win32') {
135
+ const ps =
136
+ `try {` +
137
+ `[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];` +
138
+ `$v = New-Object Windows.Security.Credentials.PasswordVault;` +
139
+ `$c = $v.Retrieve('${service}','${account}');` +
140
+ `$v.Remove($c)` +
141
+ `} catch { exit 1 }`;
142
+ const out = _run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
143
+ return out !== null;
144
+ }
145
+ return false;
146
+ }
147
+
148
+ // Read the API key from the OS keychain. Returns the key string or null.
149
+ function keychainGet() {
150
+ return keychainGetItem(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
151
+ }
152
+
153
+ // Store the API key in the OS keychain. Returns true on success, false if the
154
+ // platform tool is unavailable or the store failed.
155
+ function keychainSet(key) {
156
+ if (typeof key !== 'string' || !key) return false;
157
+ const stored = keychainSetItem(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, key, 'semalt-code API key');
158
+ if (stored) { _cache = undefined; return true; }
159
+ return false;
160
+ }
161
+
162
+ // Resolve the active API key following the documented precedence. `config` is
163
+ // the loaded config object (used only for the plaintext fallback). The result
164
+ // is cached for the env/keychain sources; the config fallback is re-read each
165
+ // call so a runtime `config set api_key` still takes effect.
166
+ function resolveApiKey(config) {
167
+ if (_cache === undefined) {
168
+ const env = process.env[ENV_VAR];
169
+ if (env && env.trim()) {
170
+ _cache = { source: 'env', key: env };
171
+ } else {
172
+ const kc = keychainGet();
173
+ if (kc && kc.trim()) {
174
+ _cache = { source: 'keychain', key: kc };
175
+ } else {
176
+ _cache = { source: null, key: null };
177
+ }
178
+ }
179
+ }
180
+ if (_cache.key) return _cache.key;
181
+ return (config && typeof config.api_key === 'string') ? config.api_key : '';
182
+ }
183
+
184
+ // Report which source the active API key comes from: 'env' | 'keychain' |
185
+ // 'config' | 'none'. Used by configShow so users can see where their key
186
+ // resolves from without ever printing the key itself.
187
+ function apiKeySource(config) {
188
+ if (_cache === undefined) resolveApiKey(config);
189
+ if (_cache.source) return _cache.source;
190
+ const cfgKey = config && typeof config.api_key === 'string' ? config.api_key : '';
191
+ return cfgKey ? 'config' : 'none';
192
+ }
193
+
194
+ // Test/CLI helper: forget the cached resolution (e.g. right after storing a new
195
+ // key) so the next resolveApiKey() re-reads env/keychain.
196
+ function _clearCache() { _cache = undefined; }
197
+
198
+ module.exports = {
199
+ ENV_VAR,
200
+ KEYCHAIN_SERVICE,
201
+ KEYCHAIN_ACCOUNT,
202
+ resolveApiKey,
203
+ apiKeySource,
204
+ keychainGet,
205
+ keychainSet,
206
+ // Generic keychain item access (Task 3.3) — used by the MCP OAuth token store.
207
+ keychainGetItem,
208
+ keychainSetItem,
209
+ keychainDeleteItem,
210
+ _clearCache,
211
+ };