pikiloop 0.4.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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Dashboard API routes: agent detection, model listing, installation.
3
+ */
4
+ import { Hono } from 'hono';
5
+ import { spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { getAgentInstallCommand, getAgentLabel, getAgentPackage } from '../../agent/npm.js';
9
+ import { loadUserConfig, saveUserConfig, applyUserConfig } from '../../core/config/user-config.js';
10
+ import { setAgentBoundModelId } from '../../agent/index.js';
11
+ import { getAgentUpdateState, checkAgentLatestVersion, manualAgentUpdate } from '../../agent/auto-update.js';
12
+ import { getDriver, getDriverCapabilities } from '../../agent/driver.js';
13
+ import { decomposeEffortSelection } from '../../core/config/runtime-config.js';
14
+ import { getActiveProfile, getProvider, peekProviderModelList, prefetchProviderModels, } from '../../model/index.js';
15
+ import { DASHBOARD_TIMEOUTS } from '../../core/constants.js';
16
+ import { withTimeoutFallback } from '../../core/utils.js';
17
+ import { runtime } from '../runtime.js';
18
+ // ---------------------------------------------------------------------------
19
+ // Constants
20
+ // ---------------------------------------------------------------------------
21
+ const AGENT_STATUS_MODELS_TIMEOUT_MS = DASHBOARD_TIMEOUTS.agentStatusModels;
22
+ const AGENT_STATUS_USAGE_TIMEOUT_MS = DASHBOARD_TIMEOUTS.agentStatusUsage;
23
+ const AGENT_STATUS_CACHE_TTL_MS = DASHBOARD_TIMEOUTS.agentStatusCacheTtl;
24
+ const AGENT_INSTALL_TIMEOUT_MS = DASHBOARD_TIMEOUTS.agentInstall;
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+ function dedupeModels(models) {
29
+ const seen = new Set();
30
+ const deduped = [];
31
+ for (const model of models) {
32
+ const id = String(model?.id || '').trim();
33
+ if (!id || seen.has(id))
34
+ continue;
35
+ seen.add(id);
36
+ deduped.push({ id, alias: model.alias?.trim() || null });
37
+ }
38
+ return deduped;
39
+ }
40
+ function runCommand(cmd, args, opts = {}) {
41
+ return new Promise(resolve => {
42
+ let stdout = '';
43
+ let stderr = '';
44
+ let finished = false;
45
+ const child = spawn(cmd, args, {
46
+ cwd: opts.cwd,
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ env: { ...process.env, npm_config_yes: 'true' },
49
+ });
50
+ const timeoutMs = Math.max(500, opts.timeoutMs ?? DASHBOARD_TIMEOUTS.runCommand);
51
+ const timer = setTimeout(() => {
52
+ if (finished)
53
+ return;
54
+ finished = true;
55
+ child.kill('SIGTERM');
56
+ resolve({ ok: false, stdout, stderr, error: `Timed out after ${Math.round(timeoutMs / 1000)}s` });
57
+ }, timeoutMs);
58
+ child.stdout?.on('data', chunk => { stdout += String(chunk); });
59
+ child.stderr?.on('data', chunk => { stderr += String(chunk); });
60
+ child.on('error', err => {
61
+ if (finished)
62
+ return;
63
+ finished = true;
64
+ clearTimeout(timer);
65
+ resolve({ ok: false, stdout, stderr, error: err.message });
66
+ });
67
+ child.on('close', code => {
68
+ if (finished)
69
+ return;
70
+ finished = true;
71
+ clearTimeout(timer);
72
+ resolve({
73
+ ok: code === 0,
74
+ stdout,
75
+ stderr,
76
+ error: code === 0 ? null : (stderr.trim() || stdout.trim() || `Exited with code ${code}`),
77
+ });
78
+ });
79
+ });
80
+ }
81
+ /**
82
+ * Parse `ENOTEMPTY: ... rename 'A' -> 'B'` paths out of npm stderr and remove
83
+ * any staging dirs (siblings of the package dir whose name starts with `.`).
84
+ * Never touches the live package dir itself.
85
+ */
86
+ function cleanupNpmStagingFromError(stderr) {
87
+ const removed = [];
88
+ const re = /rename\s+'([^']+)'\s+->\s+'([^']+)'/g;
89
+ const candidates = new Set();
90
+ for (let m; (m = re.exec(stderr));) {
91
+ candidates.add(m[1]);
92
+ candidates.add(m[2]);
93
+ }
94
+ for (const p of candidates) {
95
+ const base = path.basename(p);
96
+ if (!base.startsWith('.'))
97
+ continue;
98
+ try {
99
+ fs.rmSync(p, { recursive: true, force: true });
100
+ removed.push(p);
101
+ }
102
+ catch { /* best effort */ }
103
+ }
104
+ return removed;
105
+ }
106
+ async function installAgentViaNpm(agent, log) {
107
+ const pkg = getAgentPackage(agent);
108
+ if (!pkg)
109
+ throw new Error(`Unsupported agent: ${agent}`);
110
+ log(`Installing ${getAgentLabel(agent)} via npm...`);
111
+ let result = await runCommand('npm', ['install', '-g', `${pkg}@latest`], {
112
+ timeoutMs: AGENT_INSTALL_TIMEOUT_MS,
113
+ });
114
+ if (!result.ok && /ENOTEMPTY/.test(result.stderr)) {
115
+ const removed = cleanupNpmStagingFromError(result.stderr);
116
+ if (removed.length > 0) {
117
+ log(`Cleaned npm staging dirs after ENOTEMPTY: ${removed.join(', ')}; retrying...`);
118
+ result = await runCommand('npm', ['install', '-g', `${pkg}@latest`], {
119
+ timeoutMs: AGENT_INSTALL_TIMEOUT_MS,
120
+ });
121
+ }
122
+ }
123
+ if (!result.ok)
124
+ throw new Error(result.error || `Failed to install ${pkg}`);
125
+ log(`${getAgentLabel(agent)} installation complete.`);
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // Agent status builder
129
+ // ---------------------------------------------------------------------------
130
+ function emptyUsage(agent, error) {
131
+ return { ok: false, agent, source: null, capturedAt: null, status: null, windows: [], error };
132
+ }
133
+ async function buildAgentStatusResponse(config = loadUserConfig(), agentOptions = {}) {
134
+ const setupState = runtime.getSetupState(config, { includeVersion: true, ...agentOptions });
135
+ const workdir = runtime.getRuntimeWorkdir(config);
136
+ const defaultAgent = runtime.getRuntimeDefaultAgent(config);
137
+ const agents = await Promise.all(setupState.agents.map(async (agentState) => {
138
+ const agentId = runtime.isAgent(agentState.agent) ? agentState.agent : null;
139
+ if (!agentId) {
140
+ return {
141
+ ...agentState,
142
+ selectedModel: null,
143
+ selectedEffort: null,
144
+ workflowEnabled: false,
145
+ isDefault: false,
146
+ models: [],
147
+ usage: null,
148
+ };
149
+ }
150
+ const runtimeSelectedModel = runtime.getRuntimeModel(agentId, config);
151
+ const runtimeSelectedEffort = runtime.getRuntimeEffort(agentId, config);
152
+ let models = [];
153
+ let usage = emptyUsage(agentId, 'Agent not installed.');
154
+ let nativeConfig = null;
155
+ if (agentState.installed) {
156
+ try {
157
+ const driver = getDriver(agentId);
158
+ if (driver.getNativeConfig) {
159
+ try {
160
+ nativeConfig = driver.getNativeConfig();
161
+ }
162
+ catch { /* tolerate driver errors */ }
163
+ }
164
+ const modelFallback = runtimeSelectedModel ? [{ id: runtimeSelectedModel, alias: null }] : [];
165
+ const cachedUsage = driver.getUsage({ agent: agentId, model: runtimeSelectedModel });
166
+ // The dashboard agent card lets the user *edit* the binding — when
167
+ // they toggle the provider to "Native", the model field must show
168
+ // the agent CLI's own catalogue, not the provider's. We deliberately
169
+ // call the driver's `listModels` directly (bypassing
170
+ // `resolveAgentModels`'s BYOK substitution) so `models` is always the
171
+ // native list; the BYOK catalogue is exposed separately as
172
+ // `byokModels` below.
173
+ const [resolvedModels, resolvedUsage] = await Promise.all([
174
+ withTimeoutFallback(driver.listModels({ workdir, currentModel: runtimeSelectedModel }).then(result => dedupeModels([
175
+ ...modelFallback,
176
+ ...result.models,
177
+ ])), AGENT_STATUS_MODELS_TIMEOUT_MS, modelFallback),
178
+ driver.getUsageLive
179
+ ? withTimeoutFallback(driver.getUsageLive({ agent: agentId, model: runtimeSelectedModel }), AGENT_STATUS_USAGE_TIMEOUT_MS, cachedUsage)
180
+ : Promise.resolve(cachedUsage),
181
+ ]);
182
+ models = resolvedModels;
183
+ usage = resolvedUsage;
184
+ }
185
+ catch (err) {
186
+ const detail = err instanceof Error ? err.message : String(err);
187
+ usage = emptyUsage(agentId, detail || 'Usage query failed.');
188
+ }
189
+ }
190
+ const updateState = getAgentUpdateState(agentId);
191
+ // BYOK binding — when an active Profile exists, it overrides the native
192
+ // model/effort surfaces. Otherwise the values fall through to the user's
193
+ // runtime override and then to the driver's native config.
194
+ const activeProfile = getActiveProfile(agentId);
195
+ const byokProvider = activeProfile ? getProvider(activeProfile.providerId) : null;
196
+ const byokProviderName = byokProvider?.name || null;
197
+ // Native model/effort — what the user would run under the agent CLI's
198
+ // own auth, independent of any active BYOK Profile. AgentTab uses these
199
+ // as defaults when the user toggles a card's provider back to "Native".
200
+ const nativeSelectedModel = runtimeSelectedModel || nativeConfig?.model || null;
201
+ const nativeSelectedEffort = runtimeSelectedEffort || nativeConfig?.effort || null;
202
+ // The BYOK-bound model is what the agent will ACTUALLY run (the injector
203
+ // overrides `--model`/codex `model` at spawn). Surface it everywhere the
204
+ // UI quotes "current model" — the InputComposer pill, the cascade label,
205
+ // the agent card. Falling back to the native values when no Profile is
206
+ // bound preserves the existing native-auth path.
207
+ const selectedModel = activeProfile?.modelId || nativeSelectedModel;
208
+ const selectedEffort = activeProfile?.effort || nativeSelectedEffort;
209
+ // Likewise, the InputComposer cascade should list the bound provider's
210
+ // catalogue — those are the models the agent can actually serve through
211
+ // BYOK, not the native CLI's hardcoded list. We expose it as a SEPARATE
212
+ // `byokModels` field rather than overwriting `models`, because AgentTab's
213
+ // provider/model row falls back to `models` whenever the user temporarily
214
+ // switches the editor to the native provider — we mustn't silently leak
215
+ // BYOK ids into that view. Read from the provider-models cache
216
+ // synchronously; miss triggers a background refresh and we degrade to the
217
+ // bound model id alone so the user can at least see it selected.
218
+ let byokModels = null;
219
+ if (activeProfile && byokProvider) {
220
+ const cachedList = peekProviderModelList(byokProvider.id);
221
+ if (cachedList && cachedList.length) {
222
+ byokModels = cachedList.map(info => ({ id: info.id, alias: info.name || null }));
223
+ }
224
+ else {
225
+ prefetchProviderModels(byokProvider.id);
226
+ byokModels = [{ id: activeProfile.modelId, alias: null }];
227
+ }
228
+ }
229
+ return {
230
+ ...agentState,
231
+ selectedModel,
232
+ selectedEffort,
233
+ nativeSelectedModel,
234
+ nativeSelectedEffort,
235
+ workflowEnabled: runtime.getRuntimeWorkflowEnabled(agentId, config),
236
+ claudeAccessMode: agentId === 'claude' ? runtime.getRuntimeClaudeAccessMode(config) : undefined,
237
+ isDefault: agentId === defaultAgent,
238
+ models,
239
+ usage,
240
+ nativeConfig,
241
+ byokProviderName,
242
+ byokModels,
243
+ capabilities: getDriverCapabilities(agentId),
244
+ latestVersion: updateState?.latestVersion || null,
245
+ updateAvailable: updateState?.updateAvailable || false,
246
+ updateStatus: updateState?.status || null,
247
+ updateDetail: updateState?.detail || null,
248
+ };
249
+ }));
250
+ return { defaultAgent, workdir, agents };
251
+ }
252
+ const statusCache = { data: null, expiresAt: 0, pending: null };
253
+ function refreshStatusCache(config, opts) {
254
+ if (!statusCache.pending) {
255
+ statusCache.pending = buildAgentStatusResponse(config, opts)
256
+ .then(result => { statusCache.data = result; statusCache.expiresAt = Date.now() + AGENT_STATUS_CACHE_TTL_MS; return result; })
257
+ .finally(() => { statusCache.pending = null; });
258
+ }
259
+ return statusCache.pending;
260
+ }
261
+ function getCachedAgentStatus() {
262
+ if (statusCache.data) {
263
+ if (Date.now() >= statusCache.expiresAt)
264
+ void refreshStatusCache();
265
+ return Promise.resolve(statusCache.data);
266
+ }
267
+ return refreshStatusCache();
268
+ }
269
+ function invalidateAgentStatus(config, opts) {
270
+ statusCache.pending = null;
271
+ return refreshStatusCache(config, opts);
272
+ }
273
+ export function preloadAgentStatus() { void refreshStatusCache(); }
274
+ // ---------------------------------------------------------------------------
275
+ // Routes
276
+ // ---------------------------------------------------------------------------
277
+ const app = new Hono();
278
+ app.get('/api/agent-status', async (c) => {
279
+ return c.json(await getCachedAgentStatus());
280
+ });
281
+ app.post('/api/agent-install', async (c) => {
282
+ const body = await c.req.json();
283
+ const agent = String(body?.agent || '').trim();
284
+ if (!runtime.isAgent(agent))
285
+ return c.json({ ok: false, error: 'Invalid agent' }, 400);
286
+ runtime.log(`[agents] install requested agent=${agent} command="${getAgentInstallCommand(agent) || '(unknown)'}"`);
287
+ try {
288
+ await installAgentViaNpm(agent, msg => runtime.log(`[agents] ${msg}`));
289
+ return c.json({ ok: true, ...(await invalidateAgentStatus(loadUserConfig(), { refresh: true })) });
290
+ }
291
+ catch (err) {
292
+ const detail = err instanceof Error ? err.message : String(err);
293
+ runtime.log(`[agents] install failed agent=${agent} error=${detail}`);
294
+ return c.json({ ok: false, error: detail }, 500);
295
+ }
296
+ });
297
+ // Agent list (lightweight)
298
+ app.get('/api/agents', (c) => {
299
+ return c.json({ agents: runtime.getSetupState(loadUserConfig(), { includeVersion: true }).agents });
300
+ });
301
+ app.post('/api/agent-check-update', async (c) => {
302
+ const body = await c.req.json();
303
+ const agent = String(body?.agent || '').trim();
304
+ if (!runtime.isAgent(agent))
305
+ return c.json({ ok: false, error: 'Invalid agent' }, 400);
306
+ runtime.log(`[agents] check-update requested agent=${agent}`);
307
+ try {
308
+ const config = loadUserConfig();
309
+ const setupState = runtime.getSetupState(config, { includeVersion: true });
310
+ const agentState = setupState.agents.find(a => a.agent === agent);
311
+ if (!agentState?.installed)
312
+ return c.json({ ok: false, error: 'Agent not installed' }, 400);
313
+ const updateState = await checkAgentLatestVersion(agentState);
314
+ return c.json({ ok: true, ...updateState, ...(await invalidateAgentStatus(config)) });
315
+ }
316
+ catch (err) {
317
+ const detail = err instanceof Error ? err.message : String(err);
318
+ runtime.log(`[agents] check-update failed agent=${agent} error=${detail}`);
319
+ return c.json({ ok: false, error: detail }, 500);
320
+ }
321
+ });
322
+ app.post('/api/agent-update', async (c) => {
323
+ const body = await c.req.json();
324
+ const agent = String(body?.agent || '').trim();
325
+ if (!runtime.isAgent(agent))
326
+ return c.json({ ok: false, error: 'Invalid agent' }, 400);
327
+ runtime.log(`[agents] manual update requested agent=${agent}`);
328
+ try {
329
+ const config = loadUserConfig();
330
+ const setupState = runtime.getSetupState(config, { includeVersion: true });
331
+ const agentState = setupState.agents.find(a => a.agent === agent);
332
+ if (!agentState?.installed)
333
+ return c.json({ ok: false, error: 'Agent not installed' }, 400);
334
+ const result = await manualAgentUpdate(agentState, msg => runtime.log(`[agents] ${msg}`));
335
+ if (!result.ok)
336
+ return c.json({ ok: false, error: result.error }, 500);
337
+ return c.json({ ok: true, ...(await invalidateAgentStatus(loadUserConfig(), { refresh: true })) });
338
+ }
339
+ catch (err) {
340
+ const detail = err instanceof Error ? err.message : String(err);
341
+ runtime.log(`[agents] manual update failed agent=${agent} error=${detail}`);
342
+ return c.json({ ok: false, error: detail }, 500);
343
+ }
344
+ });
345
+ app.post('/api/runtime-agent', async (c) => {
346
+ const body = await c.req.json();
347
+ const config = loadUserConfig();
348
+ const nextConfig = { ...config };
349
+ const defaultAgent = body?.defaultAgent;
350
+ const targetAgent = body?.agent;
351
+ const model = typeof body?.model === 'string' ? body.model.trim() : '';
352
+ const rawEffort = typeof body?.effort === 'string' ? body.effort.trim().toLowerCase() : '';
353
+ // "ultra" folds max depth + Workflow orchestration into one rung; decompose
354
+ // so the stored effort is always a real --effort value and orchestration
355
+ // follows the pick (any concrete rung ⇒ off). Mirrors Bot.switchEffortForChat.
356
+ const { effort, workflow: effortWorkflow } = decomposeEffortSelection(rawEffort);
357
+ const hasEffort = rawEffort !== '';
358
+ const botRef = runtime.getBotRef();
359
+ if (defaultAgent != null) {
360
+ if (!runtime.isAgent(defaultAgent))
361
+ return c.json({ ok: false, error: 'Invalid defaultAgent' }, 400);
362
+ runtime.runtimePrefs.defaultAgent = defaultAgent;
363
+ process.env.DEFAULT_AGENT = defaultAgent;
364
+ nextConfig.defaultAgent = defaultAgent;
365
+ if (botRef)
366
+ botRef.setDefaultAgent(defaultAgent);
367
+ }
368
+ if (model || hasEffort) {
369
+ if (!runtime.isAgent(targetAgent))
370
+ return c.json({ ok: false, error: 'Invalid agent' }, 400);
371
+ if (model) {
372
+ runtime.runtimePrefs.models[targetAgent] = model;
373
+ runtime.setModelEnv(targetAgent, model);
374
+ if (targetAgent === 'claude')
375
+ nextConfig.claudeModel = model;
376
+ if (targetAgent === 'codex')
377
+ nextConfig.codexModel = model;
378
+ if (targetAgent === 'gemini')
379
+ nextConfig.geminiModel = model;
380
+ if (targetAgent === 'hermes') {
381
+ // Prefer the active BYOK Profile (the only surface `hermes acp` honors
382
+ // at runtime); fall back to the legacy `hermesModel` field only when no
383
+ // Profile is bound, so older configs keep working.
384
+ if (!setAgentBoundModelId('hermes', model))
385
+ nextConfig.hermesModel = model;
386
+ }
387
+ if (botRef)
388
+ botRef.setModelForAgent(targetAgent, model);
389
+ }
390
+ if (hasEffort) {
391
+ runtime.runtimePrefs.efforts[targetAgent] = effort;
392
+ runtime.setEffortEnv(targetAgent, effort);
393
+ if (targetAgent === 'claude')
394
+ nextConfig.claudeReasoningEffort = effort;
395
+ if (targetAgent === 'codex')
396
+ nextConfig.codexReasoningEffort = effort;
397
+ if (targetAgent === 'gemini')
398
+ nextConfig.geminiReasoningEffort = effort;
399
+ if (targetAgent === 'hermes')
400
+ nextConfig.hermesReasoningEffort = effort;
401
+ if (botRef)
402
+ botRef.setEffortForAgent(targetAgent, effort);
403
+ // The effort pick is also the single knob for orchestration: Ultra turns
404
+ // it on, any concrete rung turns it off (mutual exclusion). Only claude
405
+ // advertises the capability today.
406
+ if (targetAgent === 'claude') {
407
+ runtime.runtimePrefs.workflow.claude = effortWorkflow;
408
+ runtime.setWorkflowEnv('claude', effortWorkflow);
409
+ nextConfig.claudeWorkflowEnabled = effortWorkflow;
410
+ if (botRef)
411
+ botRef.setWorkflowEnabledForAgent('claude', effortWorkflow);
412
+ }
413
+ }
414
+ }
415
+ // Workflow orchestration toggle — independent of model/effort (orthogonal
416
+ // axis), gated to drivers that advertise the capability.
417
+ if (typeof body?.workflow === 'boolean') {
418
+ if (!runtime.isAgent(targetAgent))
419
+ return c.json({ ok: false, error: 'Invalid agent' }, 400);
420
+ if (!getDriverCapabilities(targetAgent).workflow) {
421
+ return c.json({ ok: false, error: `${targetAgent} does not support workflow orchestration` }, 400);
422
+ }
423
+ const enabled = body.workflow;
424
+ runtime.runtimePrefs.workflow[targetAgent] = enabled;
425
+ runtime.setWorkflowEnv(targetAgent, enabled);
426
+ if (targetAgent === 'claude')
427
+ nextConfig.claudeWorkflowEnabled = enabled;
428
+ if (botRef)
429
+ botRef.setWorkflowEnabledForAgent(targetAgent, enabled);
430
+ }
431
+ // Access mode — Claude only (subscription TUI vs `claude -p` API credits).
432
+ // Persisted preference; takes effect on the next spawned turn.
433
+ if (typeof body?.accessMode === 'string') {
434
+ if (targetAgent !== 'claude')
435
+ return c.json({ ok: false, error: 'accessMode is only supported for claude' }, 400);
436
+ const mode = body.accessMode;
437
+ if (mode !== 'subscription' && mode !== 'api') {
438
+ return c.json({ ok: false, error: "accessMode must be 'subscription' or 'api'" }, 400);
439
+ }
440
+ runtime.runtimePrefs.accessMode.claude = mode;
441
+ runtime.setClaudeAccessModeEnv(mode);
442
+ nextConfig.claudeAccessMode = mode;
443
+ if (botRef)
444
+ botRef.setClaudeAccessMode(mode);
445
+ }
446
+ saveUserConfig(nextConfig);
447
+ applyUserConfig(nextConfig);
448
+ return c.json({ ok: true, ...(await invalidateAgentStatus(nextConfig)) });
449
+ });
450
+ export default app;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Dashboard API routes for CLI extension management.
3
+ *
4
+ * GET /api/extensions/cli/catalog — merged recommended + detection
5
+ * POST /api/extensions/cli/refresh — { id } re-detect one entry
6
+ * POST /api/extensions/cli/auth/start — { id } start oauth-web flow
7
+ * GET /api/extensions/cli/auth/stream — SSE stream of a running session
8
+ * POST /api/extensions/cli/auth/cancel — { sessionId } kill the child
9
+ * POST /api/extensions/cli/auth/token — { id, values } token auth
10
+ * POST /api/extensions/cli/install — { id } start auto-install (npm-only)
11
+ * POST /api/extensions/cli/logout — { id } sign out
12
+ *
13
+ * Install sessions share the same SESSIONS map and event protocol as the
14
+ * oauth-web flow — the frontend connects to `/auth/stream` and cancels via
15
+ * `/auth/cancel` regardless of how the session was started.
16
+ */
17
+ import { Hono } from 'hono';
18
+ import { stream } from 'hono/streaming';
19
+ import { getCliCatalog, refreshCliStatus, startCliAuthSession, getAuthSession, cancelAuthSession, applyCliToken, logoutCli, startCliInstallSession, } from '../../agent/index.js';
20
+ const app = new Hono();
21
+ // ---------------------------------------------------------------------------
22
+ // Catalog
23
+ // ---------------------------------------------------------------------------
24
+ app.get('/api/extensions/cli/catalog', async (c) => {
25
+ try {
26
+ const items = await getCliCatalog();
27
+ return c.json({ ok: true, items });
28
+ }
29
+ catch (e) {
30
+ return c.json({ ok: false, error: e?.message || 'catalog failed', items: [] }, 500);
31
+ }
32
+ });
33
+ app.post('/api/extensions/cli/refresh', async (c) => {
34
+ try {
35
+ const { id } = await c.req.json();
36
+ if (!id?.trim())
37
+ return c.json({ ok: false, error: 'id is required' }, 400);
38
+ const status = await refreshCliStatus(id.trim());
39
+ if (!status)
40
+ return c.json({ ok: false, error: `unknown cli: ${id}` }, 404);
41
+ return c.json({ ok: true, status });
42
+ }
43
+ catch (e) {
44
+ return c.json({ ok: false, error: e?.message || 'refresh failed' }, 500);
45
+ }
46
+ });
47
+ // ---------------------------------------------------------------------------
48
+ // OAuth-web auth flow
49
+ // ---------------------------------------------------------------------------
50
+ app.post('/api/extensions/cli/auth/start', async (c) => {
51
+ try {
52
+ const { id } = await c.req.json();
53
+ if (!id?.trim())
54
+ return c.json({ ok: false, error: 'id is required' }, 400);
55
+ const result = await startCliAuthSession(id.trim());
56
+ if (!result.ok)
57
+ return c.json(result, 400);
58
+ return c.json({ ok: true, sessionId: result.sessionId });
59
+ }
60
+ catch (e) {
61
+ return c.json({ ok: false, error: e?.message || 'start failed' }, 500);
62
+ }
63
+ });
64
+ app.get('/api/extensions/cli/auth/stream', (c) => {
65
+ const sessionId = c.req.query('sessionId') || '';
66
+ if (!sessionId)
67
+ return c.json({ ok: false, error: 'sessionId is required' }, 400);
68
+ const session = getAuthSession(sessionId);
69
+ if (!session)
70
+ return c.json({ ok: false, error: 'session not found' }, 404);
71
+ c.header('Content-Type', 'text/event-stream; charset=utf-8');
72
+ c.header('Cache-Control', 'no-cache, no-transform');
73
+ c.header('Connection', 'keep-alive');
74
+ c.header('X-Accel-Buffering', 'no');
75
+ return stream(c, async (s) => {
76
+ const format = (ev) => `data: ${JSON.stringify(ev)}\n\n`;
77
+ // Replay backlog so a late client still sees what has been printed.
78
+ if (session.backlog.length) {
79
+ for (const chunk of session.backlog) {
80
+ await s.write(format({ type: 'output', chunk }));
81
+ }
82
+ }
83
+ let closed = false;
84
+ const onEvent = async (ev) => {
85
+ if (closed)
86
+ return;
87
+ try {
88
+ await s.write(format(ev));
89
+ if (ev.type === 'done') {
90
+ closed = true;
91
+ await s.write('event: close\ndata: {}\n\n');
92
+ await s.close();
93
+ }
94
+ }
95
+ catch {
96
+ closed = true;
97
+ }
98
+ };
99
+ session.events.on('event', onEvent);
100
+ if (session.done) {
101
+ await onEvent({ type: 'done', ok: session.ok, exitCode: session.exitCode });
102
+ return;
103
+ }
104
+ // Heartbeat every 15s so proxies don't close the stream on us.
105
+ const heartbeat = setInterval(() => {
106
+ if (closed) {
107
+ clearInterval(heartbeat);
108
+ return;
109
+ }
110
+ s.write(':ping\n\n').catch(() => { closed = true; });
111
+ }, 15_000);
112
+ s.onAbort(() => {
113
+ closed = true;
114
+ clearInterval(heartbeat);
115
+ session.events.off('event', onEvent);
116
+ });
117
+ });
118
+ });
119
+ app.post('/api/extensions/cli/install', async (c) => {
120
+ try {
121
+ const { id } = await c.req.json();
122
+ if (!id?.trim())
123
+ return c.json({ ok: false, error: 'id is required' }, 400);
124
+ const result = await startCliInstallSession(id.trim());
125
+ if (!result.ok)
126
+ return c.json(result, 400);
127
+ return c.json({ ok: true, sessionId: result.sessionId });
128
+ }
129
+ catch (e) {
130
+ return c.json({ ok: false, error: e?.message || 'install failed' }, 500);
131
+ }
132
+ });
133
+ app.post('/api/extensions/cli/auth/cancel', async (c) => {
134
+ try {
135
+ const { sessionId } = await c.req.json();
136
+ if (!sessionId?.trim())
137
+ return c.json({ ok: false, error: 'sessionId is required' }, 400);
138
+ const cancelled = cancelAuthSession(sessionId.trim());
139
+ return c.json({ ok: true, cancelled });
140
+ }
141
+ catch (e) {
142
+ return c.json({ ok: false, error: e?.message || 'cancel failed' }, 500);
143
+ }
144
+ });
145
+ // ---------------------------------------------------------------------------
146
+ // Token auth
147
+ // ---------------------------------------------------------------------------
148
+ app.post('/api/extensions/cli/auth/token', async (c) => {
149
+ try {
150
+ const { id, values } = await c.req.json();
151
+ if (!id?.trim())
152
+ return c.json({ ok: false, error: 'id is required' }, 400);
153
+ if (!values || typeof values !== 'object')
154
+ return c.json({ ok: false, error: 'values is required' }, 400);
155
+ const result = await applyCliToken(id.trim(), values);
156
+ return c.json(result);
157
+ }
158
+ catch (e) {
159
+ return c.json({ ok: false, error: e?.message || 'token apply failed' }, 500);
160
+ }
161
+ });
162
+ app.post('/api/extensions/cli/logout', async (c) => {
163
+ try {
164
+ const { id } = await c.req.json();
165
+ if (!id?.trim())
166
+ return c.json({ ok: false, error: 'id is required' }, 400);
167
+ const result = await logoutCli(id.trim());
168
+ return c.json(result);
169
+ }
170
+ catch (e) {
171
+ return c.json({ ok: false, error: e?.message || 'logout failed' }, 500);
172
+ }
173
+ });
174
+ export default app;