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.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- 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
|
+
}
|