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,410 @@
1
+ /**
2
+ * runtime.ts — Singleton module holding the runtime state previously
3
+ * scattered across startDashboard() closure variables.
4
+ *
5
+ * Provides bot ref management, runtime prefs (agent/model/effort),
6
+ * channel state caching, and validated setup state construction.
7
+ */
8
+ import { EventEmitter } from 'node:events';
9
+ import { applyChannelEnvFallback, loadUserConfig, resolveUserWorkdir } from '../core/config/user-config.js';
10
+ import { listAgents, resolveDefaultAgent } from '../agent/index.js';
11
+ import { collectSetupState } from '../cli/onboarding.js';
12
+ import { validateDingtalkConfig, validateDiscordConfig, validateFeishuConfig, validateSlackConfig, validateTelegramConfig, validateWecomConfig, validateWeixinConfig, } from '../core/config/validation.js';
13
+ import { shouldCacheChannelStates } from '../channels/states.js';
14
+ import { DASHBOARD_TIMEOUTS } from '../core/constants.js';
15
+ import { withTimeoutFallback } from '../core/utils.js';
16
+ import { writeScopedLog } from '../core/logging.js';
17
+ import { DEFAULT_AGENT_EFFORTS, DEFAULT_AGENT_MODELS, resolveAgentEffort, resolveAgentModel, resolveAgentWorkflowEnabled, resolveClaudeAccessMode, setAgentEffortEnv, setAgentModelEnv, setAgentWorkflowEnv, setClaudeAccessModeEnv, } from '../core/config/runtime-config.js';
18
+ // ---------------------------------------------------------------------------
19
+ // Constants
20
+ // ---------------------------------------------------------------------------
21
+ const CHANNEL_STATUS_VALIDATION_TIMEOUT_MS = DASHBOARD_TIMEOUTS.channelStatusValidation;
22
+ const CHANNEL_STATUS_CACHE_TTL_MS = DASHBOARD_TIMEOUTS.channelStatusCacheTtl;
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+ function buildLocalChannelStates(rawConfig) {
27
+ // Hydrate env-only channel tokens (docker / systemd) so the dashboard
28
+ // doesn't report "missing" when the operator passed `-e TELEGRAM_BOT_TOKEN=…`
29
+ // instead of editing setting.json.
30
+ const config = applyChannelEnvFallback(rawConfig);
31
+ const weixinBaseUrl = String(config.weixinBaseUrl || '').trim();
32
+ const weixinBotToken = String(config.weixinBotToken || '').trim();
33
+ const weixinAccountId = String(config.weixinAccountId || '').trim();
34
+ const weixinConfigured = !!(weixinBaseUrl || weixinBotToken || weixinAccountId);
35
+ const weixinReady = !!(weixinBaseUrl && weixinBotToken && weixinAccountId);
36
+ const telegramConfigured = !!String(config.telegramBotToken || '').trim();
37
+ const feishuAppId = String(config.feishuAppId || '').trim();
38
+ const feishuSecret = String(config.feishuAppSecret || '').trim();
39
+ const feishuConfigured = !!(feishuAppId || feishuSecret);
40
+ const feishuReady = !!(feishuAppId && feishuSecret);
41
+ const slackBot = String(config.slackBotToken || '').trim();
42
+ const slackApp = String(config.slackAppToken || '').trim();
43
+ const slackConfigured = !!(slackBot || slackApp);
44
+ const slackReady = !!(slackBot && slackApp);
45
+ const discordToken = String(config.discordBotToken || '').trim();
46
+ const discordConfigured = !!discordToken;
47
+ const dingtalkId = String(config.dingtalkClientId || '').trim();
48
+ const dingtalkSecret = String(config.dingtalkClientSecret || '').trim();
49
+ const dingtalkConfigured = !!(dingtalkId || dingtalkSecret);
50
+ const dingtalkReady = !!(dingtalkId && dingtalkSecret);
51
+ const wecomId = String(config.wecomBotId || '').trim();
52
+ const wecomSecret = String(config.wecomBotSecret || '').trim();
53
+ const wecomConfigured = !!(wecomId || wecomSecret);
54
+ const wecomReady = !!(wecomId && wecomSecret);
55
+ return [
56
+ {
57
+ channel: 'weixin',
58
+ configured: weixinConfigured,
59
+ ready: false,
60
+ validated: false,
61
+ status: !weixinConfigured ? 'missing' : weixinReady ? 'checking' : 'invalid',
62
+ detail: !weixinConfigured
63
+ ? 'Weixin is not configured.'
64
+ : weixinReady
65
+ ? 'Validating Weixin credentials...'
66
+ : 'Base URL, Bot Token, and Account ID are required.',
67
+ },
68
+ {
69
+ channel: 'telegram',
70
+ configured: telegramConfigured,
71
+ ready: false,
72
+ validated: false,
73
+ status: telegramConfigured ? 'checking' : 'missing',
74
+ detail: telegramConfigured ? 'Validating Telegram credentials…' : 'Telegram is not configured.',
75
+ },
76
+ {
77
+ channel: 'feishu',
78
+ configured: feishuConfigured,
79
+ ready: false,
80
+ validated: false,
81
+ status: !feishuConfigured ? 'missing' : feishuReady ? 'checking' : 'invalid',
82
+ detail: !feishuConfigured
83
+ ? 'Feishu credentials are not configured.'
84
+ : feishuReady
85
+ ? 'Validating Feishu credentials…'
86
+ : 'Both App ID and App Secret are required.',
87
+ },
88
+ {
89
+ channel: 'slack',
90
+ configured: slackConfigured,
91
+ ready: false,
92
+ validated: false,
93
+ status: !slackConfigured ? 'missing' : slackReady ? 'checking' : 'invalid',
94
+ detail: !slackConfigured
95
+ ? 'Slack is not configured.'
96
+ : slackReady
97
+ ? 'Validating Slack credentials…'
98
+ : 'Both Bot Token (xoxb-) and App-Level Token (xapp-) are required.',
99
+ },
100
+ {
101
+ channel: 'discord',
102
+ configured: discordConfigured,
103
+ ready: false,
104
+ validated: false,
105
+ status: discordConfigured ? 'checking' : 'missing',
106
+ detail: discordConfigured ? 'Validating Discord credentials…' : 'Discord is not configured.',
107
+ },
108
+ {
109
+ channel: 'dingtalk',
110
+ configured: dingtalkConfigured,
111
+ ready: false,
112
+ validated: false,
113
+ status: !dingtalkConfigured ? 'missing' : dingtalkReady ? 'checking' : 'invalid',
114
+ detail: !dingtalkConfigured
115
+ ? 'DingTalk is not configured.'
116
+ : dingtalkReady
117
+ ? 'Validating DingTalk credentials…'
118
+ : 'Both Client ID and Client Secret are required.',
119
+ },
120
+ {
121
+ channel: 'wecom',
122
+ configured: wecomConfigured,
123
+ ready: false,
124
+ validated: false,
125
+ status: !wecomConfigured ? 'missing' : wecomReady ? 'checking' : 'invalid',
126
+ detail: !wecomConfigured
127
+ ? 'WeChat Work is not configured.'
128
+ : wecomReady
129
+ ? 'Validating WeChat Work credentials…'
130
+ : 'Both Bot ID and Bot Secret are required.',
131
+ },
132
+ ];
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Runtime singleton
136
+ // ---------------------------------------------------------------------------
137
+ class Runtime {
138
+ botRef = null;
139
+ runtimePrefs = { models: {}, efforts: {}, workflow: {}, accessMode: {} };
140
+ /** Dashboard event bus — WebSocket connections subscribe to this. */
141
+ events = new EventEmitter();
142
+ /** Emit a dashboard event to all connected WebSocket clients. */
143
+ emitDashboardEvent(event) {
144
+ this.events.emit('dashboard-event', event);
145
+ }
146
+ /**
147
+ * Per-channel validation cache. Keyed by channel name so that changing
148
+ * one channel's credentials (or the channels array) doesn't invalidate
149
+ * the cached validation result for unrelated channels — which would
150
+ * otherwise flicker their UI status to "Validating…" until the next
151
+ * poll completes.
152
+ */
153
+ channelStateCache = new Map();
154
+ knownAgents = new Set(['claude', 'codex', 'gemini', 'hermes']);
155
+ defaultModels = DEFAULT_AGENT_MODELS;
156
+ defaultEfforts = DEFAULT_AGENT_EFFORTS;
157
+ // -- Bot ref management --
158
+ getBotRef() {
159
+ return this.botRef;
160
+ }
161
+ attachBot(bot) {
162
+ this.botRef = bot;
163
+ if (this.runtimePrefs.defaultAgent)
164
+ bot.setDefaultAgent(this.runtimePrefs.defaultAgent);
165
+ for (const [agent, model] of Object.entries(this.runtimePrefs.models)) {
166
+ if (this.isAgent(agent) && typeof model === 'string' && model.trim())
167
+ bot.setModelForAgent(agent, model);
168
+ }
169
+ for (const [agent, effort] of Object.entries(this.runtimePrefs.efforts)) {
170
+ if (this.isAgent(agent) && typeof effort === 'string' && effort.trim())
171
+ bot.setEffortForAgent(agent, effort);
172
+ }
173
+ for (const [agent, enabled] of Object.entries(this.runtimePrefs.workflow)) {
174
+ if (this.isAgent(agent) && typeof enabled === 'boolean')
175
+ bot.setWorkflowEnabledForAgent(agent, enabled);
176
+ }
177
+ for (const [agent, mode] of Object.entries(this.runtimePrefs.accessMode)) {
178
+ if (agent === 'claude' && (mode === 'subscription' || mode === 'api'))
179
+ bot.setClaudeAccessMode(mode);
180
+ }
181
+ // Wire stream snapshots → dashboard WebSocket
182
+ const prevPhases = new Map();
183
+ bot.onStreamSnapshot((sessionKey, snapshot) => {
184
+ this.emitDashboardEvent({ type: 'stream-update', key: sessionKey, snapshot });
185
+ // Emit sessions-changed on phase *transitions* (not every snapshot update)
186
+ // so the sidebar refreshes when a session starts running, finishes, etc.
187
+ const phase = snapshot && typeof snapshot === 'object' ? snapshot.phase : null;
188
+ const prev = prevPhases.get(sessionKey) ?? null;
189
+ if (phase !== prev) {
190
+ prevPhases.set(sessionKey, phase);
191
+ if (!phase)
192
+ prevPhases.delete(sessionKey); // clean up null entries
193
+ this.emitDashboardEvent({ type: 'sessions-changed', key: sessionKey });
194
+ }
195
+ });
196
+ }
197
+ // -- Type guards --
198
+ isAgent(value) {
199
+ return typeof value === 'string' && this.knownAgents.has(value);
200
+ }
201
+ // -- Workdir --
202
+ getRuntimeWorkdir(config) {
203
+ return this.botRef?.workdir || resolveUserWorkdir({ config });
204
+ }
205
+ getRequestWorkdir(config = loadUserConfig()) {
206
+ return this.getRuntimeWorkdir(config);
207
+ }
208
+ // -- Agent / model / effort --
209
+ getRuntimeDefaultAgent(config) {
210
+ if (this.botRef)
211
+ return this.botRef.defaultAgent;
212
+ // No bot yet (e.g. setup flow): resolve the stored preference (baseline
213
+ // 'codex' when unset) against installed CLIs so the dashboard never
214
+ // surfaces an uninstalled default the user can't run.
215
+ const preferred = this.runtimePrefs.defaultAgent || config.defaultAgent || 'codex';
216
+ return resolveDefaultAgent(preferred, listAgents().agents);
217
+ }
218
+ setModelEnv(agent, value) {
219
+ setAgentModelEnv(agent, value);
220
+ }
221
+ setEffortEnv(agent, value) {
222
+ setAgentEffortEnv(agent, value);
223
+ }
224
+ setWorkflowEnv(agent, value) {
225
+ setAgentWorkflowEnv(agent, value);
226
+ }
227
+ setClaudeAccessModeEnv(value) {
228
+ setClaudeAccessModeEnv(value);
229
+ }
230
+ getRuntimeModel(agent, config = loadUserConfig()) {
231
+ if (this.botRef)
232
+ return this.botRef.modelForAgent(agent) || this.defaultModels[agent];
233
+ return String(this.runtimePrefs.models[agent] || resolveAgentModel(config, agent)).trim();
234
+ }
235
+ getRuntimeEffort(agent, config = loadUserConfig()) {
236
+ if (this.botRef)
237
+ return this.botRef.effortForAgent(agent);
238
+ const value = String(this.runtimePrefs.efforts[agent] || resolveAgentEffort(config, agent) || '').trim().toLowerCase();
239
+ return value || null;
240
+ }
241
+ getRuntimeWorkflowEnabled(agent, config = loadUserConfig()) {
242
+ if (this.botRef)
243
+ return this.botRef.workflowEnabledForAgent(agent);
244
+ const pref = this.runtimePrefs.workflow[agent];
245
+ if (typeof pref === 'boolean')
246
+ return pref;
247
+ return resolveAgentWorkflowEnabled(config, agent);
248
+ }
249
+ getRuntimeClaudeAccessMode(config = loadUserConfig()) {
250
+ if (this.botRef)
251
+ return this.botRef.claudeAccessMode;
252
+ const pref = this.runtimePrefs.accessMode.claude;
253
+ if (pref === 'subscription' || pref === 'api')
254
+ return pref;
255
+ return resolveClaudeAccessMode(config);
256
+ }
257
+ // -- Channel state cache --
258
+ credKeyForChannel(channel, config) {
259
+ switch (channel) {
260
+ case 'weixin':
261
+ return JSON.stringify({
262
+ baseUrl: String(config.weixinBaseUrl || '').trim(),
263
+ token: String(config.weixinBotToken || '').trim(),
264
+ accountId: String(config.weixinAccountId || '').trim(),
265
+ });
266
+ case 'telegram':
267
+ return JSON.stringify({
268
+ token: String(config.telegramBotToken || '').trim(),
269
+ allowed: String(config.telegramAllowedChatIds || '').trim(),
270
+ });
271
+ case 'feishu':
272
+ return JSON.stringify({
273
+ appId: String(config.feishuAppId || '').trim(),
274
+ appSecret: String(config.feishuAppSecret || '').trim(),
275
+ });
276
+ case 'slack':
277
+ return JSON.stringify({
278
+ botToken: String(config.slackBotToken || '').trim(),
279
+ appToken: String(config.slackAppToken || '').trim(),
280
+ });
281
+ case 'discord':
282
+ return JSON.stringify({ botToken: String(config.discordBotToken || '').trim() });
283
+ case 'dingtalk':
284
+ return JSON.stringify({
285
+ clientId: String(config.dingtalkClientId || '').trim(),
286
+ clientSecret: String(config.dingtalkClientSecret || '').trim(),
287
+ });
288
+ case 'wecom':
289
+ return JSON.stringify({
290
+ botId: String(config.wecomBotId || '').trim(),
291
+ botSecret: String(config.wecomBotSecret || '').trim(),
292
+ });
293
+ }
294
+ }
295
+ validateChannel(channel, config) {
296
+ switch (channel) {
297
+ case 'weixin':
298
+ // The Weixin getupdates endpoint is a long-poll that holds the
299
+ // connection open for ~8s by default. For dashboard validation we
300
+ // only care whether the credentials are accepted (bad creds return
301
+ // an errcode quickly; good creds simply hold the poll). Use a
302
+ // tight internal timeout so we resolve well within the dashboard's
303
+ // CHANNEL_STATUS_VALIDATION_TIMEOUT_MS window — the abort path
304
+ // returns ret=0 which is treated as "valid".
305
+ return validateWeixinConfig(config.weixinBaseUrl, config.weixinBotToken, config.weixinAccountId, { timeoutMs: 2_000 }).then(r => r.state);
306
+ case 'telegram':
307
+ return validateTelegramConfig(config.telegramBotToken, config.telegramAllowedChatIds).then(r => r.state);
308
+ case 'feishu':
309
+ return validateFeishuConfig(config.feishuAppId, config.feishuAppSecret).then(r => r.state);
310
+ case 'slack':
311
+ return validateSlackConfig(config.slackBotToken, config.slackAppToken).then(r => r.state);
312
+ case 'discord':
313
+ return validateDiscordConfig(config.discordBotToken).then(r => r.state);
314
+ case 'dingtalk':
315
+ return validateDingtalkConfig(config.dingtalkClientId, config.dingtalkClientSecret).then(r => r.state);
316
+ case 'wecom':
317
+ return validateWecomConfig(config.wecomBotId, config.wecomBotSecret).then(r => r.state);
318
+ }
319
+ }
320
+ async resolveChannelStates(rawConfig) {
321
+ const config = applyChannelEnvFallback(rawConfig);
322
+ const now = Date.now();
323
+ const fallback = buildLocalChannelStates(config);
324
+ // Channels listed in the same order as buildLocalChannelStates().
325
+ const channelOrder = fallback.map(state => state.channel);
326
+ const plans = channelOrder.map((channel, idx) => {
327
+ const key = this.credKeyForChannel(channel, config);
328
+ const cached = this.channelStateCache.get(channel);
329
+ if (cached && cached.key === key && cached.expiresAt > now) {
330
+ return { channel, key, cached: cached.state, livePromise: null, fallback: fallback[idx] };
331
+ }
332
+ return { channel, key, cached: null, livePromise: this.validateChannel(channel, config), fallback: fallback[idx] };
333
+ });
334
+ const resolved = await Promise.all(plans.map(plan => {
335
+ if (plan.cached)
336
+ return Promise.resolve(plan.cached);
337
+ return withTimeoutFallback(plan.livePromise, CHANNEL_STATUS_VALIDATION_TIMEOUT_MS, plan.fallback);
338
+ }));
339
+ // Persist freshly-validated channels into the per-channel cache. If a
340
+ // channel's validation timed out, keep the live promise alive in the
341
+ // background so the next dashboard poll can pick up the real result
342
+ // instantly instead of re-issuing the network call.
343
+ plans.forEach((plan, i) => {
344
+ if (!plan.livePromise)
345
+ return;
346
+ const state = resolved[i];
347
+ if (shouldCacheChannelStates([state])) {
348
+ this.channelStateCache.set(plan.channel, {
349
+ key: plan.key,
350
+ expiresAt: now + CHANNEL_STATUS_CACHE_TTL_MS,
351
+ state,
352
+ });
353
+ return;
354
+ }
355
+ void plan.livePromise.then(bgState => {
356
+ if (!shouldCacheChannelStates([bgState]))
357
+ return;
358
+ // Skip if newer credentials have replaced the cache for this channel.
359
+ const current = this.channelStateCache.get(plan.channel);
360
+ if (current && current.key !== plan.key)
361
+ return;
362
+ this.channelStateCache.set(plan.channel, {
363
+ key: plan.key,
364
+ expiresAt: Date.now() + CHANNEL_STATUS_CACHE_TTL_MS,
365
+ state: bgState,
366
+ });
367
+ }).catch(() => { });
368
+ });
369
+ return resolved;
370
+ }
371
+ // -- Setup state --
372
+ getSetupState(config = loadUserConfig(), agentOptions = {}) {
373
+ const agents = listAgents(agentOptions).agents;
374
+ const channels = buildLocalChannelStates(applyChannelEnvFallback(config));
375
+ const readyChannel = channels.find(channel => channel.ready)?.channel;
376
+ const configuredChannel = channels.find(channel => channel.configured)?.channel;
377
+ return collectSetupState({
378
+ agents,
379
+ channel: readyChannel || configuredChannel || 'telegram',
380
+ tokenProvided: channels.some(channel => channel.configured),
381
+ channels,
382
+ });
383
+ }
384
+ async buildValidatedSetupState(config = loadUserConfig(), agentOptions = {}) {
385
+ const agents = listAgents(agentOptions).agents;
386
+ const channels = await this.resolveChannelStates(config);
387
+ const readyChannel = channels.find(channel => channel.ready)?.channel;
388
+ const configuredChannel = channels.find(channel => channel.configured)?.channel;
389
+ return collectSetupState({
390
+ agents,
391
+ channel: readyChannel || configuredChannel || 'telegram',
392
+ tokenProvided: channels.some(channel => channel.configured),
393
+ channels,
394
+ });
395
+ }
396
+ // -- Logging --
397
+ log(message, level = 'info') {
398
+ writeScopedLog('dashboard', message, { level });
399
+ }
400
+ debug(message) {
401
+ this.log(message, 'debug');
402
+ }
403
+ warn(message) {
404
+ this.log(message, 'warn');
405
+ }
406
+ }
407
+ // ---------------------------------------------------------------------------
408
+ // Singleton export
409
+ // ---------------------------------------------------------------------------
410
+ export const runtime = new Runtime();
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Hono-based dashboard HTTP server: static files and API routes.
3
+ */
4
+ import http from 'node:http';
5
+ import { Hono } from 'hono';
6
+ import { getRequestListener } from '@hono/node-server';
7
+ import { serveStatic } from '@hono/node-server/serve-static';
8
+ import path from 'node:path';
9
+ import fs from 'node:fs';
10
+ import { exec } from 'node:child_process';
11
+ import { WebSocketServer } from 'ws';
12
+ import configRoutes from './routes/config.js';
13
+ import agentRoutes, { preloadAgentStatus } from './routes/agents.js';
14
+ import sessionRoutes from './routes/sessions.js';
15
+ import extensionRoutes from './routes/extensions.js';
16
+ import cliRoutes from './routes/cli.js';
17
+ import modelsRoutes from './routes/models.js';
18
+ import localModelsRoutes from './routes/local-models.js';
19
+ import { runtime } from './runtime.js';
20
+ import { registerProcessRuntime } from '../core/process-control.js';
21
+ // ---------------------------------------------------------------------------
22
+ // Constants
23
+ // ---------------------------------------------------------------------------
24
+ const DASHBOARD_PORT_RETRY_LIMIT = 10;
25
+ const WS_KEEPALIVE_MS = 25_000;
26
+ function attachWebSocketServer(httpServer) {
27
+ const wss = new WebSocketServer({ noServer: true });
28
+ const clients = new Set();
29
+ httpServer.on('upgrade', (req, socket, head) => {
30
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
31
+ if (url.pathname !== '/ws') {
32
+ socket.destroy();
33
+ return;
34
+ }
35
+ wss.handleUpgrade(req, socket, head, (ws) => {
36
+ wss.emit('connection', ws, req);
37
+ });
38
+ });
39
+ wss.on('connection', (ws) => {
40
+ clients.add(ws);
41
+ const keepalive = setInterval(() => {
42
+ if (ws.readyState === ws.OPEN)
43
+ ws.ping();
44
+ }, WS_KEEPALIVE_MS);
45
+ ws.on('message', (raw) => {
46
+ try {
47
+ const msg = JSON.parse(String(raw));
48
+ if (msg?.type === 'ping') {
49
+ ws.send(JSON.stringify({ type: 'pong' }));
50
+ }
51
+ }
52
+ catch { /* ignore malformed messages */ }
53
+ });
54
+ ws.on('close', () => {
55
+ clients.delete(ws);
56
+ clearInterval(keepalive);
57
+ });
58
+ ws.on('error', () => {
59
+ clients.delete(ws);
60
+ clearInterval(keepalive);
61
+ });
62
+ });
63
+ const onEvent = (event) => {
64
+ const data = JSON.stringify(event);
65
+ for (const ws of clients) {
66
+ if (ws.readyState === ws.OPEN) {
67
+ ws.send(data);
68
+ }
69
+ }
70
+ };
71
+ runtime.events.on('dashboard-event', onEvent);
72
+ const closeAllClients = () => {
73
+ runtime.events.off('dashboard-event', onEvent);
74
+ for (const ws of clients)
75
+ ws.close();
76
+ clients.clear();
77
+ wss.close();
78
+ };
79
+ httpServer.on('close', closeAllClients);
80
+ return { closeAllClients };
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // Server
84
+ // ---------------------------------------------------------------------------
85
+ export async function startDashboard(opts = {}) {
86
+ const preferredPort = opts.port || 3939;
87
+ if (opts.bot)
88
+ runtime.attachBot(opts.bot);
89
+ const app = new Hono();
90
+ // -- API routes --
91
+ app.route('/', configRoutes);
92
+ app.route('/', agentRoutes);
93
+ app.route('/', sessionRoutes);
94
+ app.route('/', extensionRoutes);
95
+ app.route('/', cliRoutes);
96
+ app.route('/', modelsRoutes);
97
+ app.route('/', localModelsRoutes);
98
+ // -- Static files: serve dashboard build output --
99
+ // Resolve path relative to this file's location (src/ or dist/)
100
+ const dashboardRoot = path.resolve(import.meta.dirname, '..', '..', 'dashboard', 'dist');
101
+ // Serve /assets/* for Vite-hashed JS/CSS bundles. Filenames are
102
+ // content-hashed, so they can be cached indefinitely — a new build emits
103
+ // new filenames rather than mutating these.
104
+ app.use('/assets/*', serveStatic({
105
+ root: dashboardRoot,
106
+ onFound: (_path, c) => {
107
+ c.header('Cache-Control', 'public, max-age=31536000, immutable');
108
+ },
109
+ }));
110
+ // Serve other static files at root level (favicon, manifest, etc.).
111
+ // This mount also serves index.html for "/" (directory index), so the HTML
112
+ // shell is tagged no-cache here too — same reason as the SPA fallback below.
113
+ app.use('/*', serveStatic({
114
+ root: dashboardRoot,
115
+ onFound: (p, c) => {
116
+ if (p.endsWith('.html'))
117
+ c.header('Cache-Control', 'no-cache');
118
+ },
119
+ onNotFound: () => {
120
+ // Fall through to the SPA catch-all below
121
+ },
122
+ }));
123
+ // SPA fallback: serve index.html for all non-API routes
124
+ app.get('*', async (c) => {
125
+ // Don't catch API routes that fell through (shouldn't happen, but guard anyway)
126
+ if (c.req.path.startsWith('/api/')) {
127
+ return c.json({ error: 'Not Found' }, 404);
128
+ }
129
+ const indexPath = path.join(dashboardRoot, 'index.html');
130
+ try {
131
+ const html = fs.readFileSync(indexPath, 'utf-8');
132
+ // The HTML shell references content-hashed asset filenames, so it must
133
+ // never be cached: otherwise an open tab keeps loading stale JS after the
134
+ // server self-updates (npx pikiloop@latest) until a manual hard refresh.
135
+ c.header('Cache-Control', 'no-cache');
136
+ return c.html(html);
137
+ }
138
+ catch {
139
+ return c.text('Dashboard build not found. Run: npm run build:dashboard', 500);
140
+ }
141
+ });
142
+ // -- Process runtime registration --
143
+ let nodeServer = null;
144
+ let wsHandle = null;
145
+ const RESTART_CLOSE_TIMEOUT_MS = 3000;
146
+ const unregisterProcessRuntime = registerProcessRuntime({
147
+ label: 'dashboard',
148
+ prepareForRestart: () => new Promise(resolve => {
149
+ if (!nodeServer) {
150
+ resolve();
151
+ return;
152
+ }
153
+ // Close all WebSocket clients first — otherwise server.close() hangs
154
+ // waiting for persistent connections to end.
155
+ wsHandle?.closeAllClients();
156
+ const timer = setTimeout(resolve, RESTART_CLOSE_TIMEOUT_MS);
157
+ nodeServer.close(() => { clearTimeout(timer); resolve(); });
158
+ }),
159
+ });
160
+ // -- Start server with port retry --
161
+ return new Promise((resolve, reject) => {
162
+ let nextPort = preferredPort;
163
+ let settled = false;
164
+ const fail = (err) => {
165
+ if (settled)
166
+ return;
167
+ settled = true;
168
+ reject(err);
169
+ };
170
+ function tryListen(port) {
171
+ try {
172
+ // Create HTTP server manually so we can attach WebSocket upgrade handler
173
+ // before Hono's request listener consumes the connection.
174
+ const requestListener = getRequestListener(app.fetch);
175
+ const server = http.createServer(requestListener);
176
+ // Attach WebSocket BEFORE listening — ensures upgrade events are captured
177
+ wsHandle = attachWebSocketServer(server);
178
+ server.listen(port, () => {
179
+ if (settled)
180
+ return;
181
+ settled = true;
182
+ nodeServer = server;
183
+ const addr = server.address();
184
+ const actualPort = typeof addr === 'object' && addr ? addr.port : port;
185
+ const dashUrl = `http://localhost:${actualPort}`;
186
+ const ts = new Date().toTimeString().slice(0, 8);
187
+ process.stdout.write(`[pikiloop ${ts}] dashboard: ${dashUrl}\n`);
188
+ // Preload agent status cache so the first dashboard page load is instant
189
+ preloadAgentStatus();
190
+ if (opts.open !== false) {
191
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
192
+ exec(`${cmd} ${dashUrl}`);
193
+ }
194
+ resolve({
195
+ port: actualPort,
196
+ url: dashUrl,
197
+ attachBot(bot) {
198
+ runtime.attachBot(bot);
199
+ },
200
+ close() {
201
+ return new Promise(resolveClose => {
202
+ unregisterProcessRuntime();
203
+ if (!server) {
204
+ resolveClose();
205
+ return;
206
+ }
207
+ server.close(() => resolveClose());
208
+ });
209
+ },
210
+ });
211
+ });
212
+ // Handle EADDRINUSE by retrying on next port
213
+ server.on('error', (err) => {
214
+ if (settled)
215
+ return;
216
+ if (err.code === 'EADDRINUSE') {
217
+ if (nextPort >= preferredPort + DASHBOARD_PORT_RETRY_LIMIT) {
218
+ fail(new Error(`Dashboard ports ${preferredPort}-${preferredPort + DASHBOARD_PORT_RETRY_LIMIT} are already in use.`));
219
+ return;
220
+ }
221
+ nextPort += 1;
222
+ tryListen(nextPort);
223
+ return;
224
+ }
225
+ fail(err);
226
+ });
227
+ server.on('close', () => {
228
+ unregisterProcessRuntime();
229
+ });
230
+ }
231
+ catch (err) {
232
+ fail(err instanceof Error ? err : new Error(String(err)));
233
+ }
234
+ }
235
+ tryListen(preferredPort);
236
+ });
237
+ }