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,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent user configuration (~/.pikiloop/setting.json) load/save/sync.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { STATE_DIR_NAME, USER_CONFIG_SYNC_DEFAULT_INTERVAL_MS } from '../constants.js';
|
|
8
|
+
import { expandTilde } from '../platform.js';
|
|
9
|
+
const MANAGED_ENV_KEYS = [
|
|
10
|
+
'PIKILOOP_CHANNEL',
|
|
11
|
+
'PIKILOOP_WORKDIR',
|
|
12
|
+
'DEFAULT_AGENT',
|
|
13
|
+
'TELEGRAM_BOT_TOKEN',
|
|
14
|
+
'TELEGRAM_ALLOWED_CHAT_IDS',
|
|
15
|
+
'FEISHU_APP_ID',
|
|
16
|
+
'FEISHU_APP_SECRET',
|
|
17
|
+
'WEIXIN_BASE_URL',
|
|
18
|
+
'WEIXIN_BOT_TOKEN',
|
|
19
|
+
'WEIXIN_ACCOUNT_ID',
|
|
20
|
+
'SLACK_BOT_TOKEN',
|
|
21
|
+
'SLACK_APP_TOKEN',
|
|
22
|
+
'DISCORD_BOT_TOKEN',
|
|
23
|
+
'DINGTALK_CLIENT_ID',
|
|
24
|
+
'DINGTALK_CLIENT_SECRET',
|
|
25
|
+
'WECOM_BOT_ID',
|
|
26
|
+
'WECOM_BOT_SECRET',
|
|
27
|
+
'WECOM_ENDPOINT',
|
|
28
|
+
];
|
|
29
|
+
// Snapshot env vars present at module load — these were set externally by the
|
|
30
|
+
// process launcher (docker `-e`, shell `export`, systemd unit, ...) and reflect
|
|
31
|
+
// the operator's intent. `applyUserConfig` with `clearMissing` must never
|
|
32
|
+
// delete them, even if setting.json is silent on the same key. Without this,
|
|
33
|
+
// `docker run -e TELEGRAM_BOT_TOKEN=...` would survive only until the first
|
|
34
|
+
// config sync tick, at which point the token gets wiped from the environment.
|
|
35
|
+
const EXTERNAL_ENV_PRESET = new Set(MANAGED_ENV_KEYS.filter(key => {
|
|
36
|
+
const value = process.env[key];
|
|
37
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
38
|
+
}));
|
|
39
|
+
/**
|
|
40
|
+
* Channel-credential env vars that should hydrate a missing setting.json value
|
|
41
|
+
* back into the in-memory config. Display surfaces (dashboard channel cards,
|
|
42
|
+
* setup wizard) and channel resolution all read `config.telegramBotToken`-style
|
|
43
|
+
* fields, so if the operator only provided env vars (the docker default) the
|
|
44
|
+
* UI would otherwise show "not configured" even though the bot works fine.
|
|
45
|
+
*/
|
|
46
|
+
const ENV_TO_CONFIG_KEY = [
|
|
47
|
+
['telegramBotToken', 'TELEGRAM_BOT_TOKEN'],
|
|
48
|
+
['telegramAllowedChatIds', 'TELEGRAM_ALLOWED_CHAT_IDS'],
|
|
49
|
+
['feishuAppId', 'FEISHU_APP_ID'],
|
|
50
|
+
['feishuAppSecret', 'FEISHU_APP_SECRET'],
|
|
51
|
+
['weixinBaseUrl', 'WEIXIN_BASE_URL'],
|
|
52
|
+
['weixinBotToken', 'WEIXIN_BOT_TOKEN'],
|
|
53
|
+
['weixinAccountId', 'WEIXIN_ACCOUNT_ID'],
|
|
54
|
+
['slackBotToken', 'SLACK_BOT_TOKEN'],
|
|
55
|
+
['slackAppToken', 'SLACK_APP_TOKEN'],
|
|
56
|
+
['discordBotToken', 'DISCORD_BOT_TOKEN'],
|
|
57
|
+
['dingtalkClientId', 'DINGTALK_CLIENT_ID'],
|
|
58
|
+
['dingtalkClientSecret', 'DINGTALK_CLIENT_SECRET'],
|
|
59
|
+
['wecomBotId', 'WECOM_BOT_ID'],
|
|
60
|
+
['wecomBotSecret', 'WECOM_BOT_SECRET'],
|
|
61
|
+
['wecomEndpoint', 'WECOM_ENDPOINT'],
|
|
62
|
+
];
|
|
63
|
+
/**
|
|
64
|
+
* Return a copy of `config` with channel credential fields hydrated from
|
|
65
|
+
* matching env vars when the setting.json value is empty. The returned object
|
|
66
|
+
* must NOT be used as input to `saveUserConfig` — that would persist env-only
|
|
67
|
+
* values into setting.json, which would defeat the purpose of running with
|
|
68
|
+
* `-e TELEGRAM_BOT_TOKEN=...` (the operator wants the env var to remain the
|
|
69
|
+
* source of truth across container restarts).
|
|
70
|
+
*/
|
|
71
|
+
export function applyChannelEnvFallback(config) {
|
|
72
|
+
let next = null;
|
|
73
|
+
for (const [key, envName] of ENV_TO_CONFIG_KEY) {
|
|
74
|
+
const current = String(config[key] || '').trim();
|
|
75
|
+
if (current)
|
|
76
|
+
continue;
|
|
77
|
+
const env = String(process.env[envName] || '').trim();
|
|
78
|
+
if (!env)
|
|
79
|
+
continue;
|
|
80
|
+
if (!next)
|
|
81
|
+
next = { ...config };
|
|
82
|
+
next[key] = env;
|
|
83
|
+
}
|
|
84
|
+
return next ?? config;
|
|
85
|
+
}
|
|
86
|
+
const USER_CONFIG_DIRNAME = STATE_DIR_NAME;
|
|
87
|
+
const USER_CONFIG_FILENAME = 'setting.json';
|
|
88
|
+
let activeUserConfig = {};
|
|
89
|
+
// Parsed-config cache keyed by setting.json identity (path + mtime + size).
|
|
90
|
+
// loadUserConfig() is hit from ~60 call sites — several times per HTTP request and
|
|
91
|
+
// per agent stream (the MCP bridge resolves GUI config + per-extension OAuth) — so
|
|
92
|
+
// re-reading + JSON.parse + normalize on every call is pure waste. A cache hit costs
|
|
93
|
+
// one statSync (~100x cheaper). saveUserConfig refreshes this entry so writes are
|
|
94
|
+
// visible immediately regardless of mtime granularity. Callers treat the result as
|
|
95
|
+
// read-only and spread to mutate (the same contract getActiveUserConfig already uses).
|
|
96
|
+
let userConfigCache = null;
|
|
97
|
+
const userConfigListeners = new Set();
|
|
98
|
+
let userConfigSyncTimer = null;
|
|
99
|
+
let userConfigSyncRefCount = 0;
|
|
100
|
+
let userConfigSyncRaw = '';
|
|
101
|
+
let userConfigSyncOverrides = {};
|
|
102
|
+
const expandHomeDir = expandTilde;
|
|
103
|
+
/** Normalize workspace entries — resolve paths, deduplicate, sort by order. */
|
|
104
|
+
function normalizeWorkspaces(raw) {
|
|
105
|
+
if (!Array.isArray(raw))
|
|
106
|
+
return [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
const entries = [];
|
|
109
|
+
for (const item of raw) {
|
|
110
|
+
if (!item || typeof item !== 'object')
|
|
111
|
+
continue;
|
|
112
|
+
const rawPath = typeof item.path === 'string' ? item.path.trim() : '';
|
|
113
|
+
if (!rawPath)
|
|
114
|
+
continue;
|
|
115
|
+
const resolved = path.resolve(expandHomeDir(rawPath));
|
|
116
|
+
if (seen.has(resolved))
|
|
117
|
+
continue;
|
|
118
|
+
seen.add(resolved);
|
|
119
|
+
entries.push({
|
|
120
|
+
path: resolved,
|
|
121
|
+
name: typeof item.name === 'string' && item.name.trim()
|
|
122
|
+
? item.name.trim()
|
|
123
|
+
: path.basename(resolved),
|
|
124
|
+
order: typeof item.order === 'number' ? item.order : entries.length,
|
|
125
|
+
preferredAgent: typeof item.preferredAgent === 'string' && item.preferredAgent.trim()
|
|
126
|
+
? item.preferredAgent.trim()
|
|
127
|
+
: undefined,
|
|
128
|
+
addedAt: typeof item.addedAt === 'string' && item.addedAt.trim()
|
|
129
|
+
? item.addedAt
|
|
130
|
+
: new Date().toISOString(),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
entries.sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
|
|
134
|
+
return entries;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Single canonical config path: ~/.pikiloop/setting.json
|
|
138
|
+
* Both CLI and dashboard read/write this file exclusively.
|
|
139
|
+
*/
|
|
140
|
+
export function getDevUserConfigPath() {
|
|
141
|
+
return path.join(os.homedir(), USER_CONFIG_DIRNAME, 'dev', USER_CONFIG_FILENAME);
|
|
142
|
+
}
|
|
143
|
+
export function getUserConfigPath() {
|
|
144
|
+
const custom = (process.env.PIKILOOP_CONFIG || '').trim();
|
|
145
|
+
if (custom)
|
|
146
|
+
return path.resolve(custom);
|
|
147
|
+
return path.join(os.homedir(), USER_CONFIG_DIRNAME, USER_CONFIG_FILENAME);
|
|
148
|
+
}
|
|
149
|
+
function loadJsonFile(filePath) {
|
|
150
|
+
try {
|
|
151
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
152
|
+
const parsed = JSON.parse(raw);
|
|
153
|
+
return typeof parsed === 'object' && parsed ? normalizeUserConfig(parsed) : {};
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function normalizeUserConfig(config) {
|
|
160
|
+
const next = { ...config };
|
|
161
|
+
const workdir = typeof next.workdir === 'string' && next.workdir.trim() ? next.workdir.trim() : '';
|
|
162
|
+
if (workdir)
|
|
163
|
+
next.workdir = resolveUserWorkdir({ workdir });
|
|
164
|
+
else
|
|
165
|
+
delete next.workdir;
|
|
166
|
+
if (typeof next.browserEnabled !== 'boolean' && typeof next.browserUseProfile === 'boolean') {
|
|
167
|
+
next.browserEnabled = next.browserUseProfile;
|
|
168
|
+
}
|
|
169
|
+
if (typeof next.browserHeadless !== 'boolean' && typeof next.browserGuiHeadless === 'boolean') {
|
|
170
|
+
next.browserHeadless = next.browserGuiHeadless;
|
|
171
|
+
}
|
|
172
|
+
delete next.browserUseProfile;
|
|
173
|
+
delete next.browserCdpEndpoint;
|
|
174
|
+
delete next.browserGuiEnabled;
|
|
175
|
+
delete next.browserGuiHeadless;
|
|
176
|
+
delete next.browserGuiIsolated;
|
|
177
|
+
delete next.browserGuiUseExtension;
|
|
178
|
+
delete next.browserGuiExtensionToken;
|
|
179
|
+
if (Array.isArray(next.workspaces)) {
|
|
180
|
+
next.workspaces = normalizeWorkspaces(next.workspaces);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
delete next.workspaces;
|
|
184
|
+
}
|
|
185
|
+
return next;
|
|
186
|
+
}
|
|
187
|
+
export function loadUserConfig() {
|
|
188
|
+
const filePath = getUserConfigPath();
|
|
189
|
+
let stat;
|
|
190
|
+
try {
|
|
191
|
+
stat = fs.statSync(filePath);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
userConfigCache = null;
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
const cached = userConfigCache;
|
|
198
|
+
if (cached && cached.path === filePath && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
199
|
+
return cached.config;
|
|
200
|
+
}
|
|
201
|
+
const config = loadJsonFile(filePath);
|
|
202
|
+
userConfigCache = { path: filePath, mtimeMs: stat.mtimeMs, size: stat.size, config };
|
|
203
|
+
return config;
|
|
204
|
+
}
|
|
205
|
+
export function hasUserConfigFile() {
|
|
206
|
+
try {
|
|
207
|
+
return fs.existsSync(getUserConfigPath());
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export function getActiveUserConfig() {
|
|
214
|
+
return activeUserConfig;
|
|
215
|
+
}
|
|
216
|
+
export function saveUserConfig(config) {
|
|
217
|
+
const filePath = getUserConfigPath();
|
|
218
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
219
|
+
const normalized = { version: 1, ...normalizeUserConfig(config) };
|
|
220
|
+
fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
|
|
221
|
+
try {
|
|
222
|
+
const stat = fs.statSync(filePath);
|
|
223
|
+
userConfigCache = { path: filePath, mtimeMs: stat.mtimeMs, size: stat.size, config: normalized };
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
userConfigCache = null;
|
|
227
|
+
}
|
|
228
|
+
return filePath;
|
|
229
|
+
}
|
|
230
|
+
export function updateUserConfig(patch) {
|
|
231
|
+
return saveUserConfig({ ...loadUserConfig(), ...patch });
|
|
232
|
+
}
|
|
233
|
+
export function resolveUserWorkdir(opts = {}) {
|
|
234
|
+
const raw = String(opts.workdir
|
|
235
|
+
|| opts.config?.workdir
|
|
236
|
+
|| process.env.PIKILOOP_WORKDIR
|
|
237
|
+
|| opts.cwd
|
|
238
|
+
|| process.cwd()).trim();
|
|
239
|
+
return path.resolve(expandHomeDir(raw));
|
|
240
|
+
}
|
|
241
|
+
function buildManagedEnv(config) {
|
|
242
|
+
const configuredWorkdir = config.workdir || '';
|
|
243
|
+
return {
|
|
244
|
+
PIKILOOP_CHANNEL: String(config.channel || '').trim(),
|
|
245
|
+
PIKILOOP_WORKDIR: configuredWorkdir ? resolveUserWorkdir({ workdir: configuredWorkdir }) : '',
|
|
246
|
+
DEFAULT_AGENT: String(config.defaultAgent || '').trim(),
|
|
247
|
+
TELEGRAM_BOT_TOKEN: String(config.telegramBotToken || '').trim(),
|
|
248
|
+
TELEGRAM_ALLOWED_CHAT_IDS: String(config.telegramAllowedChatIds || '').trim(),
|
|
249
|
+
FEISHU_APP_ID: String(config.feishuAppId || '').trim(),
|
|
250
|
+
FEISHU_APP_SECRET: String(config.feishuAppSecret || '').trim(),
|
|
251
|
+
WEIXIN_BASE_URL: String(config.weixinBaseUrl || '').trim(),
|
|
252
|
+
WEIXIN_BOT_TOKEN: String(config.weixinBotToken || '').trim(),
|
|
253
|
+
WEIXIN_ACCOUNT_ID: String(config.weixinAccountId || '').trim(),
|
|
254
|
+
SLACK_BOT_TOKEN: String(config.slackBotToken || '').trim(),
|
|
255
|
+
SLACK_APP_TOKEN: String(config.slackAppToken || '').trim(),
|
|
256
|
+
DISCORD_BOT_TOKEN: String(config.discordBotToken || '').trim(),
|
|
257
|
+
DINGTALK_CLIENT_ID: String(config.dingtalkClientId || '').trim(),
|
|
258
|
+
DINGTALK_CLIENT_SECRET: String(config.dingtalkClientSecret || '').trim(),
|
|
259
|
+
WECOM_BOT_ID: String(config.wecomBotId || '').trim(),
|
|
260
|
+
WECOM_BOT_SECRET: String(config.wecomBotSecret || '').trim(),
|
|
261
|
+
WECOM_ENDPOINT: String(config.wecomEndpoint || '').trim(),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function notifyUserConfigListeners(config, changedKeys) {
|
|
265
|
+
for (const listener of userConfigListeners) {
|
|
266
|
+
try {
|
|
267
|
+
listener(config, changedKeys);
|
|
268
|
+
}
|
|
269
|
+
catch { }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function readUserConfigRaw() {
|
|
273
|
+
try {
|
|
274
|
+
return fs.readFileSync(getUserConfigPath(), 'utf-8');
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return '';
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
export function onUserConfigChange(listener) {
|
|
281
|
+
userConfigListeners.add(listener);
|
|
282
|
+
return () => userConfigListeners.delete(listener);
|
|
283
|
+
}
|
|
284
|
+
function configValuesEqual(a, b) {
|
|
285
|
+
if (Array.isArray(a) || Array.isArray(b))
|
|
286
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
287
|
+
return a === b;
|
|
288
|
+
}
|
|
289
|
+
function diffConfigKeys(prev, next) {
|
|
290
|
+
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
|
291
|
+
const changed = [];
|
|
292
|
+
for (const key of keys) {
|
|
293
|
+
if (!configValuesEqual(prev[key], next[key]))
|
|
294
|
+
changed.push(key);
|
|
295
|
+
}
|
|
296
|
+
return changed;
|
|
297
|
+
}
|
|
298
|
+
export function applyUserConfig(config, _channel, options = {}) {
|
|
299
|
+
const overwrite = options.overwrite ?? true;
|
|
300
|
+
const clearMissing = options.clearMissing ?? true;
|
|
301
|
+
const notify = options.notify ?? true;
|
|
302
|
+
const managed = buildManagedEnv(config);
|
|
303
|
+
const changedKeys = [];
|
|
304
|
+
const prevConfig = activeUserConfig;
|
|
305
|
+
for (const key of MANAGED_ENV_KEYS) {
|
|
306
|
+
const next = managed[key];
|
|
307
|
+
const prev = process.env[key] ?? '';
|
|
308
|
+
if (!next) {
|
|
309
|
+
// Never clobber an env var the launcher set externally — that's the
|
|
310
|
+
// `docker run -e ...` / `export FOO=...` contract. We only clear keys we
|
|
311
|
+
// know were written by a previous `applyUserConfig` (i.e. *not* in the
|
|
312
|
+
// boot-time snapshot).
|
|
313
|
+
if (clearMissing && key in process.env && !EXTERNAL_ENV_PRESET.has(key)) {
|
|
314
|
+
delete process.env[key];
|
|
315
|
+
changedKeys.push(key);
|
|
316
|
+
}
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (!overwrite && prev)
|
|
320
|
+
continue;
|
|
321
|
+
if (prev !== next) {
|
|
322
|
+
process.env[key] = next;
|
|
323
|
+
changedKeys.push(key);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
activeUserConfig = { ...config };
|
|
327
|
+
const configChangedKeys = diffConfigKeys(prevConfig, activeUserConfig);
|
|
328
|
+
const notifyKeys = [...new Set([...changedKeys, ...configChangedKeys])];
|
|
329
|
+
if (notify && notifyKeys.length)
|
|
330
|
+
notifyUserConfigListeners(activeUserConfig, notifyKeys);
|
|
331
|
+
return changedKeys;
|
|
332
|
+
}
|
|
333
|
+
export function setUserWorkdir(workdir, options = {}) {
|
|
334
|
+
const resolvedWorkdir = resolveUserWorkdir({ workdir });
|
|
335
|
+
const config = normalizeUserConfig({ ...loadUserConfig(), workdir: resolvedWorkdir });
|
|
336
|
+
const configPath = saveUserConfig(config);
|
|
337
|
+
// Don't pin workdir into userConfigSyncOverrides: the periodic sync reads
|
|
338
|
+
// setting.json fresh each tick, so an external `npx pikiloop@latest` that
|
|
339
|
+
// updates the file should be honored, not clobbered by an in-memory lock.
|
|
340
|
+
applyUserConfig(config, undefined, { overwrite: true, clearMissing: true, notify: options.notify ?? true });
|
|
341
|
+
return { configPath, workdir: resolvedWorkdir, config };
|
|
342
|
+
}
|
|
343
|
+
export function startUserConfigSync(options = {}) {
|
|
344
|
+
const intervalMs = Math.max(250, Math.round(options.intervalMs ?? USER_CONFIG_SYNC_DEFAULT_INTERVAL_MS));
|
|
345
|
+
if (options.overrides)
|
|
346
|
+
userConfigSyncOverrides = { ...options.overrides };
|
|
347
|
+
const syncNow = () => {
|
|
348
|
+
const raw = readUserConfigRaw();
|
|
349
|
+
if (raw === userConfigSyncRaw && userConfigSyncTimer)
|
|
350
|
+
return;
|
|
351
|
+
userConfigSyncRaw = raw;
|
|
352
|
+
const merged = { ...loadUserConfig(), ...userConfigSyncOverrides };
|
|
353
|
+
const changedKeys = applyUserConfig(merged, undefined, { overwrite: true, clearMissing: true, notify: true });
|
|
354
|
+
if (changedKeys.length)
|
|
355
|
+
options.log?.(`config reloaded from setting.json (${changedKeys.join(', ')})`);
|
|
356
|
+
};
|
|
357
|
+
syncNow();
|
|
358
|
+
userConfigSyncRefCount++;
|
|
359
|
+
if (!userConfigSyncTimer) {
|
|
360
|
+
userConfigSyncTimer = setInterval(syncNow, intervalMs);
|
|
361
|
+
userConfigSyncTimer.unref?.();
|
|
362
|
+
}
|
|
363
|
+
return () => {
|
|
364
|
+
userConfigSyncRefCount = Math.max(0, userConfigSyncRefCount - 1);
|
|
365
|
+
if (userConfigSyncRefCount > 0 || !userConfigSyncTimer)
|
|
366
|
+
return;
|
|
367
|
+
clearInterval(userConfigSyncTimer);
|
|
368
|
+
userConfigSyncTimer = null;
|
|
369
|
+
userConfigSyncRaw = '';
|
|
370
|
+
userConfigSyncOverrides = {};
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Known chat persistence
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
const KNOWN_CHAT_CONFIG_KEY = {
|
|
377
|
+
feishu: 'feishuKnownChatIds',
|
|
378
|
+
telegram: 'telegramKnownChatIds',
|
|
379
|
+
};
|
|
380
|
+
/**
|
|
381
|
+
* Append `chatId` to the persisted known-chat list for `channelType` if it
|
|
382
|
+
* isn't already there. The list survives crashes (env-based hand-off does
|
|
383
|
+
* not) so `sendStartupNotice` can greet known chats after any restart path.
|
|
384
|
+
*/
|
|
385
|
+
export function recordKnownChatId(channelType, chatId) {
|
|
386
|
+
const id = String(chatId ?? '').trim();
|
|
387
|
+
if (!id)
|
|
388
|
+
return;
|
|
389
|
+
const key = KNOWN_CHAT_CONFIG_KEY[channelType];
|
|
390
|
+
const config = loadUserConfig();
|
|
391
|
+
const existing = Array.isArray(config[key]) ? config[key] : [];
|
|
392
|
+
if (existing.includes(id))
|
|
393
|
+
return;
|
|
394
|
+
const next = [...existing, id];
|
|
395
|
+
try {
|
|
396
|
+
saveUserConfig({ ...config, [key]: next });
|
|
397
|
+
}
|
|
398
|
+
catch { }
|
|
399
|
+
}
|
|
400
|
+
/** Load the persisted known-chat list for a channel. */
|
|
401
|
+
export function loadKnownChatIds(channelType) {
|
|
402
|
+
const key = KNOWN_CHAT_CONFIG_KEY[channelType];
|
|
403
|
+
const config = loadUserConfig();
|
|
404
|
+
const list = config[key];
|
|
405
|
+
return Array.isArray(list)
|
|
406
|
+
? list.map(v => String(v ?? '').trim()).filter(Boolean)
|
|
407
|
+
: [];
|
|
408
|
+
}
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Workspace registry
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
/** Load registered workspaces from config. Returns empty array if none. */
|
|
413
|
+
export function loadWorkspaces() {
|
|
414
|
+
const config = loadUserConfig();
|
|
415
|
+
return normalizeWorkspaces(config.workspaces);
|
|
416
|
+
}
|
|
417
|
+
/** Add a workspace. Returns the new entry. Deduplicates by resolved path. */
|
|
418
|
+
export function addWorkspace(workspacePath, name) {
|
|
419
|
+
const resolved = path.resolve(expandHomeDir(workspacePath));
|
|
420
|
+
const config = loadUserConfig();
|
|
421
|
+
const workspaces = normalizeWorkspaces(config.workspaces);
|
|
422
|
+
const existing = workspaces.find(w => w.path === resolved);
|
|
423
|
+
if (existing) {
|
|
424
|
+
if (name)
|
|
425
|
+
existing.name = name;
|
|
426
|
+
saveUserConfig({ ...config, workspaces });
|
|
427
|
+
return existing;
|
|
428
|
+
}
|
|
429
|
+
const entry = {
|
|
430
|
+
path: resolved,
|
|
431
|
+
name: name?.trim() || path.basename(resolved),
|
|
432
|
+
order: workspaces.length,
|
|
433
|
+
addedAt: new Date().toISOString(),
|
|
434
|
+
};
|
|
435
|
+
workspaces.push(entry);
|
|
436
|
+
saveUserConfig({ ...config, workspaces });
|
|
437
|
+
return entry;
|
|
438
|
+
}
|
|
439
|
+
/** Remove a workspace by path. Returns true if removed. */
|
|
440
|
+
export function removeWorkspace(workspacePath) {
|
|
441
|
+
const resolved = path.resolve(expandHomeDir(workspacePath));
|
|
442
|
+
const config = loadUserConfig();
|
|
443
|
+
const workspaces = normalizeWorkspaces(config.workspaces);
|
|
444
|
+
const before = workspaces.length;
|
|
445
|
+
const filtered = workspaces.filter(w => w.path !== resolved);
|
|
446
|
+
if (filtered.length === before)
|
|
447
|
+
return false;
|
|
448
|
+
saveUserConfig({ ...config, workspaces: filtered });
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
/** Rename a workspace. Returns the updated entry or null if not found. */
|
|
452
|
+
export function renameWorkspace(workspacePath, newName) {
|
|
453
|
+
const resolved = path.resolve(expandHomeDir(workspacePath));
|
|
454
|
+
const config = loadUserConfig();
|
|
455
|
+
const workspaces = normalizeWorkspaces(config.workspaces);
|
|
456
|
+
const entry = workspaces.find(w => w.path === resolved);
|
|
457
|
+
if (!entry)
|
|
458
|
+
return null;
|
|
459
|
+
entry.name = newName.trim() || entry.name;
|
|
460
|
+
saveUserConfig({ ...config, workspaces });
|
|
461
|
+
return entry;
|
|
462
|
+
}
|
|
463
|
+
/** Reorder workspaces by providing paths in desired order. */
|
|
464
|
+
export function reorderWorkspaces(orderedPaths) {
|
|
465
|
+
const config = loadUserConfig();
|
|
466
|
+
const workspaces = normalizeWorkspaces(config.workspaces);
|
|
467
|
+
const byPath = new Map(workspaces.map(w => [w.path, w]));
|
|
468
|
+
const reordered = [];
|
|
469
|
+
const seen = new Set();
|
|
470
|
+
for (let i = 0; i < orderedPaths.length; i++) {
|
|
471
|
+
const resolved = path.resolve(expandHomeDir(orderedPaths[i]));
|
|
472
|
+
const entry = byPath.get(resolved);
|
|
473
|
+
if (entry && !seen.has(resolved)) {
|
|
474
|
+
entry.order = i;
|
|
475
|
+
reordered.push(entry);
|
|
476
|
+
seen.add(resolved);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Append any workspaces not in the ordered list
|
|
480
|
+
for (const entry of workspaces) {
|
|
481
|
+
if (!seen.has(entry.path)) {
|
|
482
|
+
entry.order = reordered.length;
|
|
483
|
+
reordered.push(entry);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
saveUserConfig({ ...config, workspaces: reordered });
|
|
487
|
+
return reordered;
|
|
488
|
+
}
|
|
489
|
+
/** Update workspace preferences (preferredAgent, etc.) */
|
|
490
|
+
export function updateWorkspace(workspacePath, patch) {
|
|
491
|
+
const resolved = path.resolve(expandHomeDir(workspacePath));
|
|
492
|
+
const config = loadUserConfig();
|
|
493
|
+
const workspaces = normalizeWorkspaces(config.workspaces);
|
|
494
|
+
const entry = workspaces.find(w => w.path === resolved);
|
|
495
|
+
if (!entry)
|
|
496
|
+
return null;
|
|
497
|
+
if (patch.name !== undefined)
|
|
498
|
+
entry.name = patch.name.trim() || entry.name;
|
|
499
|
+
if (patch.preferredAgent !== undefined)
|
|
500
|
+
entry.preferredAgent = patch.preferredAgent || undefined;
|
|
501
|
+
if (patch.order !== undefined)
|
|
502
|
+
entry.order = patch.order;
|
|
503
|
+
saveUserConfig({ ...config, workspaces });
|
|
504
|
+
return entry;
|
|
505
|
+
}
|
|
506
|
+
/** Find a workspace entry by path. */
|
|
507
|
+
export function findWorkspace(workspacePath) {
|
|
508
|
+
const resolved = path.resolve(expandHomeDir(workspacePath));
|
|
509
|
+
return loadWorkspaces().find(w => w.path === resolved) || null;
|
|
510
|
+
}
|