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,523 @@
1
+ /**
2
+ * Dashboard API routes: configuration, channels, extensions, permissions.
3
+ */
4
+ import { Hono } from 'hono';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { spawn, spawnSync } from 'node:child_process';
9
+ import { loadUserConfig, saveUserConfig, applyUserConfig, hasUserConfigFile } from '../../core/config/user-config.js';
10
+ import { expandTilde } from '../../core/platform.js';
11
+ import { readGitStatus } from '../../core/git.js';
12
+ import { isSetupReady } from '../../cli/onboarding.js';
13
+ import { validateDingtalkConfig, validateDiscordConfig, validateFeishuConfig, validateSlackConfig, validateTelegramConfig, validateWecomConfig, validateWeixinConfig, } from '../../core/config/validation.js';
14
+ import { resolveGuiIntegrationConfig } from '../../agent/mcp/bridge.js';
15
+ import { normalizeWeixinBaseUrl, startWeixinQrLogin, waitForWeixinQrLogin, } from '../../channels/weixin/api.js';
16
+ import { getConfiguredRemoteCdpUrl, getManagedBrowserStatus, launchManagedBrowserSetup, } from '../../browser-profile.js';
17
+ import { requestProcessRestart, getActiveTaskCount, } from '../../core/process-control.js';
18
+ import { getPermissionsStatus, getHostTerminalApp, isValidPermissionKey, requestPermission, } from '../platform.js';
19
+ import { VERSION } from '../../core/version.js';
20
+ import { runtime } from '../runtime.js';
21
+ import { writeScopedLog } from '../../core/logging.js';
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+ export async function buildBrowserStatusResponse(config = loadUserConfig(), browserState = getManagedBrowserStatus()) {
26
+ const gui = resolveGuiIntegrationConfig(config);
27
+ // In remote mode the local Chrome state is irrelevant — pikiloop attaches to
28
+ // the external CDP endpoint and never launches/manages a local browser. Report
29
+ // the endpoint so the dashboard shows the truth instead of "Chrome missing".
30
+ const remoteCdpUrl = gui.browserEnabled ? getConfiguredRemoteCdpUrl() : null;
31
+ return {
32
+ browser: {
33
+ status: gui.browserEnabled ? browserState.status : 'disabled',
34
+ enabled: gui.browserEnabled,
35
+ remoteCdpUrl,
36
+ headlessMode: gui.browserHeadless ? 'headless' : 'headed',
37
+ chromeInstalled: browserState.chromeInstalled,
38
+ profileCreated: browserState.profileCreated,
39
+ running: browserState.running,
40
+ pid: browserState.pid,
41
+ profileDir: browserState.profileDir || gui.browserProfileDir,
42
+ detail: !gui.browserEnabled
43
+ ? 'Browser automation is disabled. No browser MCP server will be injected into agent sessions. On macOS, operate your main browser directly with open, osascript, and screencapture when needed.'
44
+ : remoteCdpUrl
45
+ ? `Attached to an external Chrome over CDP at ${remoteCdpUrl} (PIKILOOP_BROWSER_CDP_URL). pikiloop does not launch or manage a local browser in this mode — sign in to sites from the Chrome that owns this endpoint (e.g. your sidecar's web VNC).`
46
+ : browserState.detail,
47
+ },
48
+ };
49
+ }
50
+ function isOpenTarget(value) {
51
+ return value === 'vscode'
52
+ || value === 'cursor'
53
+ || value === 'windsurf'
54
+ || value === 'finder'
55
+ || value === 'default';
56
+ }
57
+ function runOpenCommand(command, args) {
58
+ const result = spawnSync(command, args, {
59
+ encoding: 'utf8',
60
+ timeout: 5_000,
61
+ });
62
+ if (result.error)
63
+ throw result.error;
64
+ if ((result.status ?? 0) !== 0) {
65
+ const detail = String(result.stderr || result.stdout || '').trim();
66
+ throw new Error(detail || `Failed to run ${command} ${args.join(' ')}`);
67
+ }
68
+ }
69
+ function openPathWithTarget(filePath, target, isDirectory) {
70
+ if (process.platform === 'darwin') {
71
+ switch (target) {
72
+ case 'finder':
73
+ runOpenCommand('open', isDirectory ? [filePath] : ['-R', filePath]);
74
+ return;
75
+ case 'default':
76
+ runOpenCommand('open', [filePath]);
77
+ return;
78
+ case 'cursor':
79
+ runOpenCommand('open', ['-a', 'Cursor', filePath]);
80
+ return;
81
+ case 'windsurf':
82
+ runOpenCommand('open', ['-a', 'Windsurf', filePath]);
83
+ return;
84
+ case 'vscode':
85
+ default:
86
+ runOpenCommand('open', ['-a', 'Visual Studio Code', filePath]);
87
+ return;
88
+ }
89
+ }
90
+ if (process.platform === 'win32') {
91
+ switch (target) {
92
+ case 'cursor':
93
+ runOpenCommand('cursor', [filePath]);
94
+ return;
95
+ case 'windsurf':
96
+ runOpenCommand('windsurf', [filePath]);
97
+ return;
98
+ case 'finder':
99
+ case 'default':
100
+ runOpenCommand('cmd', ['/c', 'start', '', filePath]);
101
+ return;
102
+ case 'vscode':
103
+ default:
104
+ runOpenCommand('code', [filePath]);
105
+ return;
106
+ }
107
+ }
108
+ switch (target) {
109
+ case 'cursor':
110
+ runOpenCommand('cursor', [filePath]);
111
+ return;
112
+ case 'windsurf':
113
+ runOpenCommand('windsurf', [filePath]);
114
+ return;
115
+ case 'finder':
116
+ case 'default':
117
+ runOpenCommand('xdg-open', [filePath]);
118
+ return;
119
+ case 'vscode':
120
+ default:
121
+ runOpenCommand('code', [filePath]);
122
+ return;
123
+ }
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Routes
127
+ // ---------------------------------------------------------------------------
128
+ const app = new Hono();
129
+ // Full state (config from file only)
130
+ app.get('/api/state', async (c) => {
131
+ const config = loadUserConfig();
132
+ const setupState = await runtime.buildValidatedSetupState(config);
133
+ const permissions = getPermissionsStatus();
134
+ const botRef = runtime.getBotRef();
135
+ return c.json({
136
+ version: VERSION,
137
+ ready: isSetupReady(setupState),
138
+ configExists: hasUserConfigFile(),
139
+ config,
140
+ runtimeWorkdir: runtime.getRuntimeWorkdir(config),
141
+ setupState,
142
+ permissions,
143
+ hostApp: getHostTerminalApp(),
144
+ platform: process.platform,
145
+ pid: process.pid,
146
+ nodeVersion: process.versions.node,
147
+ bot: botRef ? {
148
+ workdir: botRef.workdir,
149
+ defaultAgent: botRef.defaultAgent,
150
+ uptime: Date.now() - botRef.startedAt,
151
+ connected: botRef.connected,
152
+ stats: botRef.stats,
153
+ activeTasks: botRef.activeTasks.size,
154
+ sessions: botRef.sessionStates.size,
155
+ } : null,
156
+ });
157
+ });
158
+ // Host info
159
+ app.get('/api/host', (c) => {
160
+ const botRef = runtime.getBotRef();
161
+ if (botRef)
162
+ return c.json(botRef.getHostData());
163
+ const cpus = os.cpus();
164
+ const [one, five, fifteen] = os.loadavg();
165
+ return c.json({
166
+ hostName: os.hostname(), cpuModel: cpus[0]?.model || 'unknown',
167
+ cpuCount: cpus.length, totalMem: os.totalmem(), freeMem: os.freemem(),
168
+ loadAverage: { one, five, fifteen },
169
+ platform: process.platform, arch: os.arch(),
170
+ });
171
+ });
172
+ // Permissions
173
+ app.get('/api/permissions', (c) => {
174
+ const data = { ...getPermissionsStatus(), hostApp: getHostTerminalApp() };
175
+ return c.json(data);
176
+ });
177
+ // Save config (to ~/.pikiloop/setting.json). Channel reconciliation is
178
+ // handled by ChannelSupervisor via the onUserConfigChange listener — adding,
179
+ // removing, or swapping credentials of an IM channel takes effect in-process
180
+ // without restarting pikiloop.
181
+ app.post('/api/config', async (c) => {
182
+ const body = await c.req.json();
183
+ const merged = { ...loadUserConfig(), ...body };
184
+ const configPath = saveUserConfig(merged);
185
+ applyUserConfig(loadUserConfig());
186
+ return c.json({ ok: true, configPath });
187
+ });
188
+ // Validate Telegram token
189
+ app.post('/api/validate-telegram-token', async (c) => {
190
+ const body = await c.req.json();
191
+ const result = await validateTelegramConfig(body.token || '', body.allowedChatIds || '');
192
+ return c.json({
193
+ ok: result.state.ready,
194
+ error: result.state.ready ? null : result.state.detail,
195
+ bot: result.bot,
196
+ normalizedAllowedChatIds: result.normalizedAllowedChatIds,
197
+ });
198
+ });
199
+ // Validate Feishu credentials
200
+ app.post('/api/validate-feishu-config', async (c) => {
201
+ const body = await c.req.json();
202
+ const startedAt = Date.now();
203
+ const rawAppId = String(body.appId || '').trim();
204
+ const maskedAppId = !rawAppId
205
+ ? '(missing)'
206
+ : rawAppId.length <= 10
207
+ ? rawAppId
208
+ : `${rawAppId.slice(0, 6)}...${rawAppId.slice(-4)}`;
209
+ writeScopedLog('dashboard', `[feishu-config] request app=${maskedAppId}`, { level: 'debug' });
210
+ const result = await validateFeishuConfig(body.appId || '', body.appSecret || '');
211
+ writeScopedLog('dashboard', `[feishu-config] result app=${maskedAppId} ok=${result.state.ready} status=${result.state.status} elapsedMs=${Date.now() - startedAt}`, { level: 'debug' });
212
+ return c.json({
213
+ ok: result.state.ready,
214
+ error: result.state.ready ? null : result.state.detail,
215
+ app: result.app,
216
+ });
217
+ });
218
+ // Validate Weixin credentials
219
+ app.post('/api/validate-weixin-config', async (c) => {
220
+ const body = await c.req.json();
221
+ const result = await validateWeixinConfig(body.baseUrl || '', body.botToken || '', body.accountId || '');
222
+ return c.json({
223
+ ok: result.state.ready,
224
+ error: result.state.ready ? null : result.state.detail,
225
+ account: result.account,
226
+ normalizedBaseUrl: result.normalizedBaseUrl,
227
+ });
228
+ });
229
+ // Validate Slack credentials
230
+ app.post('/api/validate-slack-config', async (c) => {
231
+ const body = await c.req.json();
232
+ const result = await validateSlackConfig(body.botToken || '', body.appToken || '');
233
+ return c.json({
234
+ ok: result.state.ready,
235
+ error: result.state.ready ? null : result.state.detail,
236
+ bot: result.bot,
237
+ });
238
+ });
239
+ // Validate Discord credentials
240
+ app.post('/api/validate-discord-config', async (c) => {
241
+ const body = await c.req.json();
242
+ const result = await validateDiscordConfig(body.botToken || '');
243
+ return c.json({
244
+ ok: result.state.ready,
245
+ error: result.state.ready ? null : result.state.detail,
246
+ bot: result.bot,
247
+ });
248
+ });
249
+ // Validate DingTalk credentials
250
+ app.post('/api/validate-dingtalk-config', async (c) => {
251
+ const body = await c.req.json();
252
+ const result = await validateDingtalkConfig(body.clientId || '', body.clientSecret || '');
253
+ return c.json({
254
+ ok: result.state.ready,
255
+ error: result.state.ready ? null : result.state.detail,
256
+ app: result.app,
257
+ });
258
+ });
259
+ // Validate WeChat Work (企业微信) credentials
260
+ app.post('/api/validate-wecom-config', async (c) => {
261
+ const body = await c.req.json();
262
+ const result = await validateWecomConfig(body.botId || '', body.botSecret || '');
263
+ return c.json({
264
+ ok: result.state.ready,
265
+ error: result.state.ready ? null : result.state.detail,
266
+ bot: result.bot,
267
+ });
268
+ });
269
+ // Start Weixin QR login
270
+ app.post('/api/weixin-login/start', async (c) => {
271
+ const body = await c.req.json();
272
+ const result = await startWeixinQrLogin({
273
+ baseUrl: normalizeWeixinBaseUrl(body.baseUrl || ''),
274
+ sessionKey: body.sessionKey || undefined,
275
+ });
276
+ return c.json(result, result.ok ? 200 : 500);
277
+ });
278
+ // Wait for Weixin QR login
279
+ app.post('/api/weixin-login/wait', async (c) => {
280
+ const body = await c.req.json();
281
+ const result = await waitForWeixinQrLogin({
282
+ baseUrl: normalizeWeixinBaseUrl(body.baseUrl || ''),
283
+ sessionKey: String(body.sessionKey || '').trim(),
284
+ });
285
+ return c.json(result, result.ok ? 200 : 500);
286
+ });
287
+ // Open macOS preferences
288
+ app.post('/api/open-preferences', async (c) => {
289
+ const body = await c.req.json();
290
+ const permission = String(body.permission || '');
291
+ if (!isValidPermissionKey(permission)) {
292
+ return c.json({
293
+ ok: false,
294
+ action: 'unsupported',
295
+ granted: false,
296
+ requiresManualGrant: false,
297
+ error: 'Invalid permission.',
298
+ }, 400);
299
+ }
300
+ const result = requestPermission(permission);
301
+ runtime.log(`[permissions] permission=${permission} action=${result.action} granted=${result.granted} manual=${result.requiresManualGrant} ok=${result.ok}`);
302
+ return c.json(result, result.ok ? 200 : 500);
303
+ });
304
+ // Restart process
305
+ app.post('/api/restart', (c) => {
306
+ // A successful restart exits this process, so its result can never reach the
307
+ // client — the route fires it async and returns ok:true. But the restart is
308
+ // REFUSED while any turn is still running (requestProcessRestart guards on
309
+ // active tasks so it won't kill in-flight work). Surface that refusal
310
+ // synchronously: otherwise the dashboard sends ok:true into a "reconnecting"
311
+ // wait for a restart that never happens and shows a generic failure, with no
312
+ // hint that a running task is the reason.
313
+ const activeTasks = getActiveTaskCount();
314
+ if (activeTasks > 0) {
315
+ return c.json({
316
+ ok: false,
317
+ activeTasks,
318
+ error: `${activeTasks} task(s) still running — can't restart. Wait for them to finish or stop them, then retry.`,
319
+ }, 409);
320
+ }
321
+ setTimeout(() => {
322
+ void requestProcessRestart({ log: message => runtime.log(message) });
323
+ }, 50);
324
+ return c.json({ ok: true });
325
+ });
326
+ // Switch workdir
327
+ app.post('/api/switch-workdir', async (c) => {
328
+ const body = await c.req.json();
329
+ const newPath = body.path;
330
+ if (!newPath)
331
+ return c.json({ ok: false, error: 'Missing path' }, 400);
332
+ const resolvedPath = path.resolve(expandTilde(String(newPath)));
333
+ const botRef = runtime.getBotRef();
334
+ if (botRef) {
335
+ botRef.switchWorkdir(resolvedPath);
336
+ return c.json({ ok: true, workdir: botRef.workdir });
337
+ }
338
+ const { setUserWorkdir } = await import('../../core/config/user-config.js');
339
+ const saved = setUserWorkdir(resolvedPath);
340
+ return c.json({ ok: true, workdir: saved.workdir });
341
+ });
342
+ // Browser profile status
343
+ app.get('/api/browser', async (c) => {
344
+ const config = loadUserConfig();
345
+ const data = await buildBrowserStatusResponse(config);
346
+ return c.json(data);
347
+ });
348
+ // Launch managed browser profile for login/setup
349
+ app.post('/api/browser/setup', async (c) => {
350
+ runtime.log('[browser] setup requested');
351
+ try {
352
+ const config = loadUserConfig();
353
+ const gui = resolveGuiIntegrationConfig(config);
354
+ if (!gui.browserEnabled) {
355
+ return c.json({
356
+ ok: false,
357
+ error: 'Browser automation is disabled. Enable it first if you want pikiloop to launch the managed browser profile.',
358
+ }, 400);
359
+ }
360
+ // Remote mode: pikiloop attaches to an external CDP endpoint and owns no
361
+ // local Chrome. There is nothing to launch — return the (enabled) status as
362
+ // a clean no-op instead of failing with "Chrome is not available".
363
+ if (getConfiguredRemoteCdpUrl()) {
364
+ runtime.log('[browser] setup skipped: PIKILOOP_BROWSER_CDP_URL configured (external CDP, no local browser to launch)');
365
+ return c.json({ ok: true, ...(await buildBrowserStatusResponse(config)) });
366
+ }
367
+ const launch = launchManagedBrowserSetup();
368
+ runtime.log(`[browser] launched managed profile at ${launch.profileDir} pid=${launch.pid ?? 'unknown'}`);
369
+ const payload = await buildBrowserStatusResponse(config, launch);
370
+ return c.json({
371
+ ok: true,
372
+ browser: {
373
+ ...payload.browser,
374
+ detail: launch.running
375
+ ? 'Managed browser is open. Sign in to the sites you want pikiloop to reuse. If it is still open later, pikiloop will close it automatically before browser automation starts.'
376
+ : payload.browser.detail,
377
+ },
378
+ });
379
+ }
380
+ catch (err) {
381
+ const detail = err instanceof Error ? err.message : String(err);
382
+ runtime.log(`[browser] setup failed: ${detail}`);
383
+ return c.json({ ok: false, error: detail }, 500);
384
+ }
385
+ });
386
+ // List directory entries for tree browser
387
+ app.get('/api/ls-dir', (c) => {
388
+ const dir = c.req.query('path') || os.homedir();
389
+ const includeFiles = c.req.query('files') === '1';
390
+ const includeHidden = c.req.query('hidden') === '1';
391
+ try {
392
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
393
+ const dirs = entries
394
+ .filter(e => (includeHidden || !e.name.startsWith('.')) && (includeFiles || e.isDirectory()))
395
+ .map(e => ({ name: e.name, path: path.join(dir, e.name), isDir: e.isDirectory() }))
396
+ .sort((a, b) => {
397
+ if (a.isDir !== b.isDir)
398
+ return a.isDir ? -1 : 1;
399
+ return a.name.localeCompare(b.name);
400
+ });
401
+ const isGit = fs.existsSync(path.join(dir, '.git'));
402
+ return c.json({ ok: true, path: dir, parent: path.dirname(dir), dirs, isGit });
403
+ }
404
+ catch (err) {
405
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
406
+ }
407
+ });
408
+ // Git changes for a directory (uncommitted + staged)
409
+ app.get('/api/git-changes', (c) => {
410
+ const dir = c.req.query('path');
411
+ if (!dir)
412
+ return c.json({ ok: false, error: 'path is required' }, 400);
413
+ try {
414
+ if (!fs.existsSync(path.join(dir, '.git'))) {
415
+ return c.json({ ok: true, changes: [], isGit: false });
416
+ }
417
+ // --no-optional-locks avoids contention with other git processes
418
+ const result = spawnSync('git', ['diff', '--name-status', 'HEAD', '--no-renames'], {
419
+ cwd: dir,
420
+ timeout: 5_000,
421
+ encoding: 'utf-8',
422
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
423
+ });
424
+ const lines = (result.stdout || '').trim().split('\n').filter(Boolean);
425
+ const changes = lines.map(line => {
426
+ const [status, ...rest] = line.split('\t');
427
+ const file = rest.join('\t');
428
+ return {
429
+ status: status === 'A' ? 'added'
430
+ : status === 'D' ? 'deleted'
431
+ : 'modified',
432
+ file,
433
+ path: path.join(dir, file),
434
+ };
435
+ });
436
+ return c.json({ ok: true, changes, isGit: true });
437
+ }
438
+ catch (err) {
439
+ return c.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, 500);
440
+ }
441
+ });
442
+ // Git status summary for a workspace: branch, ahead/behind, dirty count.
443
+ // Backed by the same readGitStatus helper that feeds the IM /status command.
444
+ app.get('/api/workspace-git', (c) => {
445
+ const dir = c.req.query('path');
446
+ if (!dir)
447
+ return c.json({ ok: false, error: 'path is required' }, 400);
448
+ const git = readGitStatus(dir);
449
+ return c.json({ ok: true, isGit: git !== null, git });
450
+ });
451
+ // Open file/directory in a selected editor or file browser
452
+ app.post('/api/open-in-editor', async (c) => {
453
+ try {
454
+ const body = await c.req.json();
455
+ const filePath = typeof body?.filePath === 'string' ? body.filePath.trim() : '';
456
+ const target = isOpenTarget(body?.target) ? body.target : 'vscode';
457
+ if (!filePath)
458
+ return c.json({ ok: false, error: 'filePath is required' }, 400);
459
+ if (!fs.existsSync(filePath))
460
+ return c.json({ ok: false, error: 'Path not found' }, 404);
461
+ const stat = fs.statSync(filePath);
462
+ openPathWithTarget(filePath, target, stat.isDirectory());
463
+ return c.json({ ok: true });
464
+ }
465
+ catch (err) {
466
+ const detail = err instanceof Error ? err.message : String(err);
467
+ runtime.log(`[open-in-editor] failed: ${detail}`);
468
+ return c.json({ ok: false, error: detail }, 500);
469
+ }
470
+ });
471
+ // Open git diff for a file in the selected editor
472
+ app.post('/api/open-diff', async (c) => {
473
+ try {
474
+ const body = await c.req.json();
475
+ const filePath = typeof body?.filePath === 'string' ? body.filePath.trim() : '';
476
+ const target = isOpenTarget(body?.target) ? body.target : 'vscode';
477
+ if (!filePath)
478
+ return c.json({ ok: false, error: 'filePath is required' }, 400);
479
+ const dir = path.dirname(filePath);
480
+ const relFile = path.basename(filePath);
481
+ // Write the original (HEAD) version to a temp file
482
+ const origResult = spawnSync('git', ['show', `HEAD:${path.relative(findGitRoot(dir), filePath)}`], {
483
+ cwd: dir,
484
+ timeout: 5_000,
485
+ encoding: 'buffer',
486
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
487
+ });
488
+ if (origResult.status !== 0) {
489
+ // New file — no HEAD version, just open the file
490
+ openPathWithTarget(filePath, target, false);
491
+ return c.json({ ok: true });
492
+ }
493
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pikiloop-diff-'));
494
+ const origPath = path.join(tmpDir, `${relFile}.orig`);
495
+ fs.writeFileSync(origPath, origResult.stdout);
496
+ // Use editor CLI diff command (fire-and-forget)
497
+ const cli = target === 'cursor' ? 'cursor' : target === 'windsurf' ? 'windsurf' : 'code';
498
+ const child = spawn(cli, ['--diff', origPath, filePath], {
499
+ cwd: dir,
500
+ stdio: 'ignore',
501
+ detached: true,
502
+ });
503
+ child.unref();
504
+ // Clean up temp after a delay
505
+ setTimeout(() => fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => { }), 30_000);
506
+ return c.json({ ok: true });
507
+ }
508
+ catch (err) {
509
+ const detail = err instanceof Error ? err.message : String(err);
510
+ runtime.log(`[open-diff] failed: ${detail}`);
511
+ return c.json({ ok: false, error: detail }, 500);
512
+ }
513
+ });
514
+ function findGitRoot(dir) {
515
+ let current = dir;
516
+ while (current !== path.dirname(current)) {
517
+ if (fs.existsSync(path.join(current, '.git')))
518
+ return current;
519
+ current = path.dirname(current);
520
+ }
521
+ return dir;
522
+ }
523
+ export default app;