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,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
+ }