pikiclaw 0.2.35

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.
@@ -0,0 +1,840 @@
1
+ /**
2
+ * dashboard.ts — Web dashboard server for pikiclaw configuration and monitoring.
3
+ *
4
+ * All config is read from / written to ~/.pikiclaw/setting.json (no env vars).
5
+ */
6
+ import http from 'node:http';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import { exec, execFileSync, execSync } from 'node:child_process';
11
+ import { collectSetupState, isSetupReady } from './onboarding.js';
12
+ import { loadUserConfig, saveUserConfig, applyUserConfig, resolveUserWorkdir, setUserWorkdir, hasUserConfigFile } from './user-config.js';
13
+ import { listAgents, getSessionTail, getSessions, listModels } from './code-agent.js';
14
+ import { getDriver } from './agent-driver.js';
15
+ import { VERSION } from './bot.js';
16
+ import { validateFeishuConfig, validateTelegramConfig } from './config-validation.js';
17
+ import { getDashboardHtml } from './dashboard-ui.js';
18
+ import { shouldCacheChannelStates } from './channel-states.js';
19
+ import { formatActiveTaskRestartError, getActiveTaskCount, registerProcessRuntime, requestProcessRestart, } from './process-control.js';
20
+ import { getSessionStatusForBot } from './session-status.js';
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers
23
+ // ---------------------------------------------------------------------------
24
+ // Codex model discovery has to cold-start the app-server on some machines.
25
+ // If we fall back too quickly the dashboard only shows the current model.
26
+ const AGENT_STATUS_MODELS_TIMEOUT_MS = 4_000;
27
+ const AGENT_STATUS_USAGE_TIMEOUT_MS = 1_500;
28
+ const CHANNEL_STATUS_VALIDATION_TIMEOUT_MS = 3_000;
29
+ const CHANNEL_STATUS_CACHE_TTL_MS = 20_000;
30
+ const DEFAULT_SESSION_PAGE_SIZE = 6;
31
+ const MAX_SESSION_PAGE_SIZE = 30;
32
+ function buildLocalChannelStates(config) {
33
+ const telegramConfigured = !!String(config.telegramBotToken || '').trim();
34
+ const feishuAppId = String(config.feishuAppId || '').trim();
35
+ const feishuSecret = String(config.feishuAppSecret || '').trim();
36
+ const feishuConfigured = !!(feishuAppId || feishuSecret);
37
+ const feishuReady = !!(feishuAppId && feishuSecret);
38
+ return [
39
+ {
40
+ channel: 'telegram',
41
+ configured: telegramConfigured,
42
+ ready: false,
43
+ validated: false,
44
+ status: telegramConfigured ? 'checking' : 'missing',
45
+ detail: telegramConfigured ? 'Validating Telegram credentials…' : 'Telegram is not configured.',
46
+ },
47
+ {
48
+ channel: 'feishu',
49
+ configured: feishuConfigured,
50
+ ready: false,
51
+ validated: false,
52
+ status: !feishuConfigured ? 'missing' : feishuReady ? 'checking' : 'invalid',
53
+ detail: !feishuConfigured
54
+ ? 'Feishu credentials are not configured.'
55
+ : feishuReady
56
+ ? 'Validating Feishu credentials…'
57
+ : 'Both App ID and App Secret are required.',
58
+ },
59
+ ];
60
+ }
61
+ function getSetupState(config = loadUserConfig(), agentOptions = {}) {
62
+ const agents = listAgents(agentOptions).agents;
63
+ const channels = buildLocalChannelStates(config);
64
+ const readyChannel = channels.find(channel => channel.ready)?.channel;
65
+ const configuredChannel = channels.find(channel => channel.configured)?.channel;
66
+ return collectSetupState({
67
+ agents,
68
+ channel: readyChannel || configuredChannel || 'telegram',
69
+ tokenProvided: channels.some(channel => channel.configured),
70
+ channels,
71
+ });
72
+ }
73
+ function withTimeoutFallback(promise, timeoutMs, fallback) {
74
+ return new Promise(resolve => {
75
+ let settled = false;
76
+ const timer = setTimeout(() => {
77
+ if (settled)
78
+ return;
79
+ settled = true;
80
+ resolve(fallback);
81
+ }, timeoutMs);
82
+ promise
83
+ .then(result => {
84
+ if (settled)
85
+ return;
86
+ settled = true;
87
+ clearTimeout(timer);
88
+ resolve(result);
89
+ })
90
+ .catch(() => {
91
+ if (settled)
92
+ return;
93
+ settled = true;
94
+ clearTimeout(timer);
95
+ resolve(fallback);
96
+ });
97
+ });
98
+ }
99
+ function parsePageNumber(value, fallback = 0) {
100
+ const parsed = Number.parseInt(value || '', 10);
101
+ if (!Number.isFinite(parsed) || parsed < 0)
102
+ return fallback;
103
+ return parsed;
104
+ }
105
+ function parsePageSize(value, fallback = DEFAULT_SESSION_PAGE_SIZE) {
106
+ const parsed = Number.parseInt(value || '', 10);
107
+ if (!Number.isFinite(parsed) || parsed <= 0)
108
+ return fallback;
109
+ return Math.min(parsed, MAX_SESSION_PAGE_SIZE);
110
+ }
111
+ function paginateSessionResult(result, page, limit) {
112
+ const sessions = Array.isArray(result.sessions) ? result.sessions : [];
113
+ const total = sessions.length;
114
+ const totalPages = Math.max(1, Math.ceil(total / limit));
115
+ const safePage = Math.min(page, totalPages - 1);
116
+ const start = safePage * limit;
117
+ return {
118
+ ok: result.ok,
119
+ error: result.error,
120
+ sessions: sessions.slice(start, start + limit),
121
+ page: safePage,
122
+ limit,
123
+ total,
124
+ totalPages,
125
+ hasMore: safePage + 1 < totalPages,
126
+ };
127
+ }
128
+ function enrichSessionResultWithRuntimeStatus(result, bot) {
129
+ return {
130
+ ...result,
131
+ sessions: result.sessions.map(session => {
132
+ const status = bot ? getSessionStatusForBot(bot, session) : { isCurrent: false, isRunning: !!session.running };
133
+ return {
134
+ ...session,
135
+ running: status.isRunning,
136
+ isCurrent: status.isCurrent,
137
+ };
138
+ }),
139
+ };
140
+ }
141
+ function dedupeModels(models) {
142
+ const seen = new Set();
143
+ const deduped = [];
144
+ for (const model of models) {
145
+ const id = String(model?.id || '').trim();
146
+ if (!id || seen.has(id))
147
+ continue;
148
+ seen.add(id);
149
+ deduped.push({ id, alias: model.alias?.trim() || null });
150
+ }
151
+ return deduped;
152
+ }
153
+ const permissionPaneUrls = {
154
+ accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility',
155
+ screenRecording: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
156
+ fullDiskAccess: 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
157
+ };
158
+ function runJxa(script, timeout = 5_000) {
159
+ try {
160
+ return String(execFileSync('osascript', ['-l', 'JavaScript', '-e', script], { encoding: 'utf8', timeout })).trim().toLowerCase();
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ function checkAccessibilityPermission() {
167
+ try {
168
+ execFileSync('osascript', ['-e', 'tell application "System Events" to keystroke ""'], { stdio: 'ignore', timeout: 4_000 });
169
+ return true;
170
+ }
171
+ catch { }
172
+ const output = runJxa('ObjC.bindFunction("CGPreflightPostEventAccess", ["bool", []]); console.log($.CGPreflightPostEventAccess());', 4_000);
173
+ if (output == null)
174
+ return null;
175
+ return output === 'true';
176
+ }
177
+ function requestAccessibilityPermission() {
178
+ return runJxa('ObjC.bindFunction("CGRequestPostEventAccess", ["bool", []]); console.log($.CGRequestPostEventAccess());', 6_000) !== null;
179
+ }
180
+ function checkScreenRecordingPermission() {
181
+ const screenshotPath = path.join(os.tmpdir(), `.pikiclaw_perm_test_${process.pid}_${Date.now()}.png`);
182
+ try {
183
+ execFileSync('screencapture', ['-x', screenshotPath], { stdio: 'ignore', timeout: 5_000 });
184
+ return true;
185
+ }
186
+ catch { }
187
+ finally {
188
+ try {
189
+ fs.rmSync(screenshotPath, { force: true });
190
+ }
191
+ catch { }
192
+ }
193
+ const output = runJxa('ObjC.bindFunction("CGPreflightScreenCaptureAccess", ["bool", []]); console.log($.CGPreflightScreenCaptureAccess());', 4_000);
194
+ if (output == null)
195
+ return null;
196
+ return output === 'true';
197
+ }
198
+ function requestScreenRecordingPermission() {
199
+ return runJxa('ObjC.bindFunction("CGRequestScreenCaptureAccess", ["bool", []]); console.log($.CGRequestScreenCaptureAccess());', 6_000) !== null;
200
+ }
201
+ function openPermissionSettings(permission) {
202
+ const pane = permissionPaneUrls[permission];
203
+ if (!pane)
204
+ return false;
205
+ try {
206
+ execFileSync('open', [pane], { stdio: 'ignore', timeout: 3_000 });
207
+ return true;
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ }
213
+ function checkPermissions() {
214
+ const r = {};
215
+ if (process.platform !== 'darwin') {
216
+ r.accessibility = { granted: true, checkable: false, detail: 'N/A' };
217
+ r.screenRecording = { granted: true, checkable: false, detail: 'N/A' };
218
+ r.fullDiskAccess = { granted: true, checkable: false, detail: 'N/A' };
219
+ return r;
220
+ }
221
+ const accessibilityGranted = checkAccessibilityPermission();
222
+ r.accessibility = {
223
+ granted: accessibilityGranted === true,
224
+ checkable: true,
225
+ detail: accessibilityGranted === true ? '已授权' : '未授权',
226
+ };
227
+ const screenRecordingGranted = checkScreenRecordingPermission();
228
+ r.screenRecording = {
229
+ granted: screenRecordingGranted === true,
230
+ checkable: true,
231
+ detail: screenRecordingGranted === true ? '已授权' : '未授权',
232
+ };
233
+ try {
234
+ execSync(`ls "${os.homedir()}/Library/Mail" 2>/dev/null`, { timeout: 3000 });
235
+ r.fullDiskAccess = { granted: true, checkable: true, detail: '已授权' };
236
+ }
237
+ catch {
238
+ r.fullDiskAccess = { granted: false, checkable: true, detail: '未授权' };
239
+ }
240
+ return r;
241
+ }
242
+ function requestPermission(permission) {
243
+ if (process.platform !== 'darwin') {
244
+ return {
245
+ ok: false,
246
+ action: 'unsupported',
247
+ granted: true,
248
+ requiresManualGrant: false,
249
+ error: 'Permission requests are only supported on macOS.',
250
+ };
251
+ }
252
+ const current = checkPermissions()[permission];
253
+ if (current?.granted) {
254
+ return {
255
+ ok: true,
256
+ action: 'already_granted',
257
+ granted: true,
258
+ requiresManualGrant: false,
259
+ };
260
+ }
261
+ if (permission === 'accessibility') {
262
+ const prompted = requestAccessibilityPermission();
263
+ if (!prompted) {
264
+ const openedSettings = openPermissionSettings(permission);
265
+ return openedSettings
266
+ ? { ok: true, action: 'opened_settings', granted: false, requiresManualGrant: true }
267
+ : { ok: false, action: 'unsupported', granted: false, requiresManualGrant: true, error: 'Failed to trigger Accessibility permission request.' };
268
+ }
269
+ return {
270
+ ok: true,
271
+ action: 'prompted',
272
+ granted: !!checkPermissions().accessibility?.granted,
273
+ requiresManualGrant: true,
274
+ };
275
+ }
276
+ if (permission === 'screenRecording') {
277
+ const prompted = requestScreenRecordingPermission();
278
+ if (!prompted) {
279
+ const openedSettings = openPermissionSettings(permission);
280
+ return openedSettings
281
+ ? { ok: true, action: 'opened_settings', granted: false, requiresManualGrant: true }
282
+ : { ok: false, action: 'unsupported', granted: false, requiresManualGrant: true, error: 'Failed to trigger Screen Recording permission request.' };
283
+ }
284
+ return {
285
+ ok: true,
286
+ action: 'prompted',
287
+ granted: !!checkPermissions().screenRecording?.granted,
288
+ requiresManualGrant: true,
289
+ };
290
+ }
291
+ if (permission === 'fullDiskAccess') {
292
+ const openedSettings = openPermissionSettings(permission);
293
+ return openedSettings
294
+ ? { ok: true, action: 'opened_settings', granted: false, requiresManualGrant: true }
295
+ : { ok: false, action: 'unsupported', granted: false, requiresManualGrant: true, error: 'Failed to open Full Disk Access settings.' };
296
+ }
297
+ return { ok: false, action: 'unsupported', granted: false, requiresManualGrant: true, error: 'Unknown permission.' };
298
+ }
299
+ async function parseJsonBody(req) {
300
+ return new Promise((resolve, reject) => {
301
+ const chunks = [];
302
+ req.on('data', (c) => chunks.push(c));
303
+ req.on('end', () => { try {
304
+ resolve(JSON.parse(Buffer.concat(chunks).toString()));
305
+ }
306
+ catch {
307
+ reject(new Error('Invalid JSON'));
308
+ } });
309
+ req.on('error', reject);
310
+ });
311
+ }
312
+ function json(res, data, status = 200) {
313
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
314
+ res.end(JSON.stringify(data));
315
+ }
316
+ function dashboardLog(message) {
317
+ const ts = new Date().toTimeString().slice(0, 8);
318
+ process.stdout.write(`[dashboard ${ts}] ${message}\n`);
319
+ }
320
+ // ---------------------------------------------------------------------------
321
+ // Server
322
+ // ---------------------------------------------------------------------------
323
+ export async function startDashboard(opts = {}) {
324
+ const preferredPort = opts.port || 3939;
325
+ let botRef = opts.bot || null;
326
+ const htmlContent = getDashboardHtml({ version: VERSION });
327
+ const runtimePrefs = {
328
+ models: {},
329
+ efforts: {},
330
+ };
331
+ let channelStateCache = null;
332
+ const knownAgents = new Set(['claude', 'codex', 'gemini']);
333
+ const defaultModels = {
334
+ claude: 'claude-opus-4-6',
335
+ codex: 'gpt-5.4',
336
+ gemini: 'gemini-3.1-pro-preview',
337
+ };
338
+ const defaultEfforts = {
339
+ claude: 'high',
340
+ codex: 'xhigh',
341
+ };
342
+ function isAgent(value) {
343
+ return typeof value === 'string' && knownAgents.has(value);
344
+ }
345
+ function emptyUsage(agent, error) {
346
+ return { ok: false, agent, source: null, capturedAt: null, status: null, windows: [], error };
347
+ }
348
+ function channelStateCacheKey(config) {
349
+ return JSON.stringify({
350
+ telegramBotToken: String(config.telegramBotToken || '').trim(),
351
+ telegramAllowedChatIds: String(config.telegramAllowedChatIds || '').trim(),
352
+ feishuAppId: String(config.feishuAppId || '').trim(),
353
+ feishuAppSecret: String(config.feishuAppSecret || '').trim(),
354
+ });
355
+ }
356
+ async function resolveChannelStates(config) {
357
+ const key = channelStateCacheKey(config);
358
+ const now = Date.now();
359
+ if (channelStateCache && channelStateCache.key === key && channelStateCache.expiresAt > now) {
360
+ return channelStateCache.channels;
361
+ }
362
+ const fallback = buildLocalChannelStates(config);
363
+ const [telegram, feishu] = await Promise.all([
364
+ withTimeoutFallback(validateTelegramConfig(config.telegramBotToken, config.telegramAllowedChatIds).then(result => result.state), CHANNEL_STATUS_VALIDATION_TIMEOUT_MS, fallback[0]),
365
+ withTimeoutFallback(validateFeishuConfig(config.feishuAppId, config.feishuAppSecret).then(result => result.state), CHANNEL_STATUS_VALIDATION_TIMEOUT_MS, fallback[1]),
366
+ ]);
367
+ const channels = [telegram, feishu];
368
+ if (shouldCacheChannelStates(channels)) {
369
+ channelStateCache = {
370
+ key,
371
+ expiresAt: now + CHANNEL_STATUS_CACHE_TTL_MS,
372
+ channels,
373
+ };
374
+ }
375
+ return channels;
376
+ }
377
+ async function buildValidatedSetupState(config = loadUserConfig(), agentOptions = {}) {
378
+ const agents = listAgents(agentOptions).agents;
379
+ const channels = await resolveChannelStates(config);
380
+ const readyChannel = channels.find(channel => channel.ready)?.channel;
381
+ const configuredChannel = channels.find(channel => channel.configured)?.channel;
382
+ return collectSetupState({
383
+ agents,
384
+ channel: readyChannel || configuredChannel || 'telegram',
385
+ tokenProvided: channels.some(channel => channel.configured),
386
+ channels,
387
+ });
388
+ }
389
+ function modelEnv(agent) {
390
+ switch (agent) {
391
+ case 'claude': return process.env.CLAUDE_MODEL;
392
+ case 'codex': return process.env.CODEX_MODEL;
393
+ case 'gemini': return process.env.GEMINI_MODEL;
394
+ }
395
+ }
396
+ function effortEnv(agent) {
397
+ switch (agent) {
398
+ case 'claude': return process.env.CLAUDE_REASONING_EFFORT;
399
+ case 'codex': return process.env.CODEX_REASONING_EFFORT;
400
+ case 'gemini': return undefined;
401
+ }
402
+ }
403
+ function configModel(config, agent) {
404
+ switch (agent) {
405
+ case 'claude': return String(config.claudeModel || '').trim() || undefined;
406
+ case 'codex': return String(config.codexModel || '').trim() || undefined;
407
+ case 'gemini': return String(config.geminiModel || '').trim() || undefined;
408
+ }
409
+ }
410
+ function configEffort(config, agent) {
411
+ switch (agent) {
412
+ case 'claude': return String(config.claudeReasoningEffort || '').trim().toLowerCase() || undefined;
413
+ case 'codex': return String(config.codexReasoningEffort || '').trim().toLowerCase() || undefined;
414
+ case 'gemini': return undefined;
415
+ }
416
+ }
417
+ function setModelEnv(agent, value) {
418
+ switch (agent) {
419
+ case 'claude':
420
+ process.env.CLAUDE_MODEL = value;
421
+ break;
422
+ case 'codex':
423
+ process.env.CODEX_MODEL = value;
424
+ break;
425
+ case 'gemini':
426
+ process.env.GEMINI_MODEL = value;
427
+ break;
428
+ }
429
+ }
430
+ function setEffortEnv(agent, value) {
431
+ switch (agent) {
432
+ case 'claude':
433
+ process.env.CLAUDE_REASONING_EFFORT = value;
434
+ break;
435
+ case 'codex':
436
+ process.env.CODEX_REASONING_EFFORT = value;
437
+ break;
438
+ case 'gemini': break;
439
+ }
440
+ }
441
+ function getRuntimeDefaultAgent(config) {
442
+ if (botRef)
443
+ return botRef.defaultAgent;
444
+ const raw = String(runtimePrefs.defaultAgent || config.defaultAgent || 'codex').trim().toLowerCase();
445
+ return isAgent(raw) ? raw : 'codex';
446
+ }
447
+ function getRuntimeWorkdir(config) {
448
+ return botRef?.workdir || resolveUserWorkdir({ config });
449
+ }
450
+ function getRequestWorkdir(config = loadUserConfig()) {
451
+ return getRuntimeWorkdir(config);
452
+ }
453
+ function getRuntimeModel(agent, config = loadUserConfig()) {
454
+ if (botRef)
455
+ return botRef.modelForAgent(agent) || defaultModels[agent];
456
+ return String(runtimePrefs.models[agent] || configModel(config, agent) || modelEnv(agent) || defaultModels[agent]).trim();
457
+ }
458
+ function getRuntimeEffort(agent, config = loadUserConfig()) {
459
+ if (agent === 'gemini')
460
+ return null;
461
+ if (botRef)
462
+ return botRef.effortForAgent(agent);
463
+ const value = String(runtimePrefs.efforts[agent] || configEffort(config, agent) || effortEnv(agent) || defaultEfforts[agent] || '').trim().toLowerCase();
464
+ return value || null;
465
+ }
466
+ async function buildAgentStatusResponse(config = loadUserConfig()) {
467
+ const setupState = getSetupState(config, { includeVersion: true });
468
+ const workdir = getRuntimeWorkdir(config);
469
+ const defaultAgent = getRuntimeDefaultAgent(config);
470
+ const agents = await Promise.all(setupState.agents.map(async (agentState) => {
471
+ const agentId = isAgent(agentState.agent) ? agentState.agent : null;
472
+ if (!agentId) {
473
+ return {
474
+ ...agentState,
475
+ selectedModel: null,
476
+ selectedEffort: null,
477
+ isDefault: false,
478
+ models: [],
479
+ usage: null,
480
+ };
481
+ }
482
+ const selectedModel = getRuntimeModel(agentId, config);
483
+ const selectedEffort = getRuntimeEffort(agentId, config);
484
+ let models = [];
485
+ let usage = emptyUsage(agentId, 'Agent not installed.');
486
+ if (agentState.installed) {
487
+ const modelFallback = selectedModel ? [{ id: selectedModel, alias: null }] : [];
488
+ try {
489
+ const driver = getDriver(agentId);
490
+ const cachedUsage = driver.getUsage({ agent: agentId, model: selectedModel });
491
+ const [resolvedModels, resolvedUsage] = await Promise.all([
492
+ withTimeoutFallback(listModels(agentId, { workdir, currentModel: selectedModel }).then(result => dedupeModels([
493
+ ...modelFallback,
494
+ ...result.models,
495
+ ])), AGENT_STATUS_MODELS_TIMEOUT_MS, modelFallback),
496
+ driver.getUsageLive
497
+ ? withTimeoutFallback(driver.getUsageLive({ agent: agentId, model: selectedModel }), AGENT_STATUS_USAGE_TIMEOUT_MS, cachedUsage)
498
+ : Promise.resolve(cachedUsage),
499
+ ]);
500
+ models = resolvedModels;
501
+ usage = resolvedUsage;
502
+ }
503
+ catch (err) {
504
+ const detail = err instanceof Error ? err.message : String(err);
505
+ usage = emptyUsage(agentId, detail || 'Usage query failed.');
506
+ }
507
+ }
508
+ return {
509
+ ...agentState,
510
+ selectedModel,
511
+ selectedEffort,
512
+ isDefault: agentId === defaultAgent,
513
+ models,
514
+ usage,
515
+ };
516
+ }));
517
+ return { defaultAgent, workdir, agents };
518
+ }
519
+ const server = http.createServer(async (req, res) => {
520
+ const url = new URL(req.url || '/', 'http://localhost');
521
+ const method = req.method?.toUpperCase() || 'GET';
522
+ try {
523
+ if (url.pathname === '/' && method === 'GET') {
524
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
525
+ return res.end(htmlContent);
526
+ }
527
+ // Full state (config from file only)
528
+ if (url.pathname === '/api/state' && method === 'GET') {
529
+ const config = loadUserConfig();
530
+ const setupState = await buildValidatedSetupState(config);
531
+ const permissions = checkPermissions();
532
+ return json(res, {
533
+ version: VERSION,
534
+ ready: isSetupReady(setupState),
535
+ configExists: hasUserConfigFile(),
536
+ config,
537
+ runtimeWorkdir: getRuntimeWorkdir(config),
538
+ setupState,
539
+ permissions,
540
+ platform: process.platform,
541
+ pid: process.pid,
542
+ nodeVersion: process.versions.node,
543
+ bot: botRef ? {
544
+ workdir: botRef.workdir,
545
+ defaultAgent: botRef.defaultAgent,
546
+ uptime: Date.now() - botRef.startedAt,
547
+ connected: botRef.connected,
548
+ stats: botRef.stats,
549
+ activeTasks: botRef.activeTasks.size,
550
+ sessions: botRef.sessionStates.size,
551
+ } : null,
552
+ });
553
+ }
554
+ if (url.pathname === '/api/agent-status' && method === 'GET') {
555
+ return json(res, await buildAgentStatusResponse());
556
+ }
557
+ // Host info
558
+ if (url.pathname === '/api/host' && method === 'GET') {
559
+ if (botRef)
560
+ return json(res, botRef.getHostData());
561
+ const cpus = os.cpus();
562
+ return json(res, {
563
+ hostName: os.hostname(), cpuModel: cpus[0]?.model || 'unknown',
564
+ cpuCount: cpus.length, totalMem: os.totalmem(), freeMem: os.freemem(),
565
+ platform: process.platform, arch: os.arch(),
566
+ });
567
+ }
568
+ // Agents
569
+ if (url.pathname === '/api/agents' && method === 'GET') {
570
+ return json(res, { agents: getSetupState(loadUserConfig(), { includeVersion: true }).agents });
571
+ }
572
+ // Sessions (per agent)
573
+ if (url.pathname.match(/^\/api\/sessions\/[^/]+$/) && method === 'GET') {
574
+ const agent = url.pathname.split('/')[3];
575
+ const config = loadUserConfig();
576
+ const workdir = getRequestWorkdir(config);
577
+ const page = parsePageNumber(url.searchParams.get('page'));
578
+ const limit = parsePageSize(url.searchParams.get('limit'));
579
+ dashboardLog(`[sessions] endpoint=single agent=${agent} resolvedWorkdir=${workdir} exists=${fs.existsSync(workdir)} ` +
580
+ `configWorkdir=${String(config.workdir || '(none)')} botWorkdir=${botRef?.workdir || '(none)'} ` +
581
+ `page=${page} limit=${limit}`);
582
+ const result = await getSessions({ agent, workdir });
583
+ const paged = paginateSessionResult(enrichSessionResultWithRuntimeStatus(result, botRef), page, limit);
584
+ dashboardLog(`[sessions] endpoint=single agent=${agent} ok=${paged.ok} total=${paged.total} ` +
585
+ `returned=${paged.sessions.length} error=${paged.error || '(none)'}`);
586
+ return json(res, paged);
587
+ }
588
+ // All sessions (all agents, for swim lane view)
589
+ if (url.pathname === '/api/sessions' && method === 'GET') {
590
+ const config = loadUserConfig();
591
+ const workdir = getRequestWorkdir(config);
592
+ const page = parsePageNumber(url.searchParams.get('page'));
593
+ const limit = parsePageSize(url.searchParams.get('limit'));
594
+ dashboardLog(`[sessions] endpoint=all resolvedWorkdir=${workdir} exists=${fs.existsSync(workdir)} ` +
595
+ `configWorkdir=${String(config.workdir || '(none)')} botWorkdir=${botRef?.workdir || '(none)'} ` +
596
+ `page=${page} limit=${limit}`);
597
+ const agents = listAgents().agents.filter(a => a.installed);
598
+ const result = {};
599
+ await Promise.all(agents.map(async (a) => {
600
+ const agentResult = await getSessions({ agent: a.agent, workdir });
601
+ result[a.agent] = paginateSessionResult(enrichSessionResultWithRuntimeStatus(agentResult, botRef), page, limit);
602
+ const paged = result[a.agent];
603
+ dashboardLog(`[sessions] endpoint=all agent=${a.agent} ok=${!!paged?.ok} total=${paged?.total ?? 0} ` +
604
+ `returned=${Array.isArray(paged?.sessions) ? paged.sessions.length : 0} error=${paged?.error || '(none)'}`);
605
+ }));
606
+ return json(res, result);
607
+ }
608
+ // Session detail (tail messages)
609
+ if (url.pathname.match(/^\/api\/session-detail\/[^/]+\/[^/]+$/) && method === 'GET') {
610
+ const parts = url.pathname.split('/');
611
+ const agent = parts[3];
612
+ const sessionId = decodeURIComponent(parts[4]);
613
+ const config = loadUserConfig();
614
+ const workdir = getRequestWorkdir(config);
615
+ const limit = parseInt(url.searchParams.get('limit') || '6', 10);
616
+ dashboardLog(`[sessions] endpoint=detail agent=${agent} session=${sessionId} limit=${limit} resolvedWorkdir=${workdir} ` +
617
+ `exists=${fs.existsSync(workdir)} configWorkdir=${String(config.workdir || '(none)')} botWorkdir=${botRef?.workdir || '(none)'}`);
618
+ const tail = await getSessionTail({ agent, sessionId, workdir, limit });
619
+ dashboardLog(`[sessions] endpoint=detail agent=${agent} session=${sessionId} ok=${tail.ok} messages=${tail.messages.length} error=${tail.error || '(none)'}`);
620
+ return json(res, tail);
621
+ }
622
+ // Permissions
623
+ if (url.pathname === '/api/permissions' && method === 'GET') {
624
+ return json(res, checkPermissions());
625
+ }
626
+ // Save config (to ~/.pikiclaw/setting.json)
627
+ if (url.pathname === '/api/config' && method === 'POST') {
628
+ const body = await parseJsonBody(req);
629
+ const merged = { ...loadUserConfig(), ...body };
630
+ const configPath = saveUserConfig(merged);
631
+ applyUserConfig(loadUserConfig());
632
+ return json(res, { ok: true, configPath });
633
+ }
634
+ if (url.pathname === '/api/runtime-agent' && method === 'POST') {
635
+ const body = await parseJsonBody(req);
636
+ const config = loadUserConfig();
637
+ const nextConfig = { ...config };
638
+ const defaultAgent = body?.defaultAgent;
639
+ const targetAgent = body?.agent;
640
+ const model = typeof body?.model === 'string' ? body.model.trim() : '';
641
+ const effort = typeof body?.effort === 'string' ? body.effort.trim().toLowerCase() : '';
642
+ if (defaultAgent != null) {
643
+ if (!isAgent(defaultAgent))
644
+ return json(res, { ok: false, error: 'Invalid defaultAgent' }, 400);
645
+ runtimePrefs.defaultAgent = defaultAgent;
646
+ process.env.DEFAULT_AGENT = defaultAgent;
647
+ nextConfig.defaultAgent = defaultAgent;
648
+ if (botRef)
649
+ botRef.setDefaultAgent(defaultAgent);
650
+ }
651
+ if (model || effort) {
652
+ if (!isAgent(targetAgent))
653
+ return json(res, { ok: false, error: 'Invalid agent' }, 400);
654
+ if (model) {
655
+ runtimePrefs.models[targetAgent] = model;
656
+ setModelEnv(targetAgent, model);
657
+ if (targetAgent === 'claude')
658
+ nextConfig.claudeModel = model;
659
+ if (targetAgent === 'codex')
660
+ nextConfig.codexModel = model;
661
+ if (targetAgent === 'gemini')
662
+ nextConfig.geminiModel = model;
663
+ if (botRef)
664
+ botRef.setModelForAgent(targetAgent, model);
665
+ }
666
+ if (effort && targetAgent !== 'gemini') {
667
+ runtimePrefs.efforts[targetAgent] = effort;
668
+ setEffortEnv(targetAgent, effort);
669
+ if (targetAgent === 'claude')
670
+ nextConfig.claudeReasoningEffort = effort;
671
+ if (targetAgent === 'codex')
672
+ nextConfig.codexReasoningEffort = effort;
673
+ if (botRef)
674
+ botRef.setEffortForAgent(targetAgent, effort);
675
+ }
676
+ }
677
+ saveUserConfig(nextConfig);
678
+ applyUserConfig(nextConfig);
679
+ return json(res, { ok: true, ...(await buildAgentStatusResponse(nextConfig)) });
680
+ }
681
+ // Validate Telegram token
682
+ if (url.pathname === '/api/validate-telegram-token' && method === 'POST') {
683
+ const body = await parseJsonBody(req);
684
+ const result = await validateTelegramConfig(body.token || '', body.allowedChatIds || '');
685
+ return json(res, {
686
+ ok: result.state.ready,
687
+ error: result.state.ready ? null : result.state.detail,
688
+ bot: result.bot,
689
+ normalizedAllowedChatIds: result.normalizedAllowedChatIds,
690
+ });
691
+ }
692
+ // Validate Feishu credentials
693
+ if (url.pathname === '/api/validate-feishu-config' && method === 'POST') {
694
+ const body = await parseJsonBody(req);
695
+ const startedAt = Date.now();
696
+ const rawAppId = String(body.appId || '').trim();
697
+ const maskedAppId = !rawAppId
698
+ ? '(missing)'
699
+ : rawAppId.length <= 10
700
+ ? rawAppId
701
+ : `${rawAppId.slice(0, 6)}...${rawAppId.slice(-4)}`;
702
+ const ts = new Date().toISOString().slice(11, 19);
703
+ process.stdout.write(`[dashboard ${ts}] [feishu-config] request app=${maskedAppId}\n`);
704
+ const result = await validateFeishuConfig(body.appId || '', body.appSecret || '');
705
+ process.stdout.write(`[dashboard ${ts}] [feishu-config] result app=${maskedAppId} ok=${result.state.ready} status=${result.state.status} elapsedMs=${Date.now() - startedAt}\n`);
706
+ return json(res, {
707
+ ok: result.state.ready,
708
+ error: result.state.ready ? null : result.state.detail,
709
+ app: result.app,
710
+ });
711
+ }
712
+ // Open macOS preferences
713
+ if (url.pathname === '/api/open-preferences' && method === 'POST') {
714
+ const body = await parseJsonBody(req);
715
+ const permission = String(body.permission || '');
716
+ if (!permissionPaneUrls[permission]) {
717
+ return json(res, {
718
+ ok: false,
719
+ action: 'unsupported',
720
+ granted: false,
721
+ requiresManualGrant: false,
722
+ error: 'Invalid permission.',
723
+ }, 400);
724
+ }
725
+ const result = requestPermission(permission);
726
+ dashboardLog(`[permissions] permission=${permission} action=${result.action} granted=${result.granted} manual=${result.requiresManualGrant} ok=${result.ok}`);
727
+ return json(res, result, result.ok ? 200 : 500);
728
+ }
729
+ // Restart process
730
+ if (url.pathname === '/api/restart' && method === 'POST') {
731
+ const activeTasks = getActiveTaskCount();
732
+ if (activeTasks > 0) {
733
+ return json(res, { ok: false, error: formatActiveTaskRestartError(activeTasks) }, 409);
734
+ }
735
+ json(res, { ok: true });
736
+ setTimeout(() => {
737
+ void requestProcessRestart({ log: message => dashboardLog(message) });
738
+ }, 50);
739
+ return;
740
+ }
741
+ // Switch workdir
742
+ if (url.pathname === '/api/switch-workdir' && method === 'POST') {
743
+ const body = await parseJsonBody(req);
744
+ const newPath = body.path;
745
+ if (!newPath)
746
+ return json(res, { ok: false, error: 'Missing path' }, 400);
747
+ const resolvedPath = path.resolve(String(newPath).replace(/^~/, process.env.HOME || ''));
748
+ if (botRef) {
749
+ botRef.switchWorkdir(resolvedPath);
750
+ return json(res, { ok: true, workdir: botRef.workdir });
751
+ }
752
+ const saved = setUserWorkdir(resolvedPath);
753
+ return json(res, { ok: true, workdir: saved.workdir });
754
+ }
755
+ // List directory entries for tree browser
756
+ if (url.pathname === '/api/ls-dir' && method === 'GET') {
757
+ const dir = url.searchParams.get('path') || os.homedir();
758
+ try {
759
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
760
+ const dirs = entries
761
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
762
+ .map(e => ({ name: e.name, path: path.join(dir, e.name) }))
763
+ .sort((a, b) => a.name.localeCompare(b.name));
764
+ const isGit = fs.existsSync(path.join(dir, '.git'));
765
+ return json(res, { ok: true, path: dir, parent: path.dirname(dir), dirs, isGit });
766
+ }
767
+ catch (err) {
768
+ return json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 400);
769
+ }
770
+ }
771
+ res.writeHead(404);
772
+ res.end('Not Found');
773
+ }
774
+ catch (err) {
775
+ json(res, { error: err instanceof Error ? err.message : String(err) }, 500);
776
+ }
777
+ });
778
+ const unregisterProcessRuntime = registerProcessRuntime({
779
+ label: 'dashboard',
780
+ prepareForRestart: () => new Promise(resolve => {
781
+ if (!server.listening) {
782
+ resolve();
783
+ return;
784
+ }
785
+ server.close(() => resolve());
786
+ }),
787
+ });
788
+ return new Promise((resolve, reject) => {
789
+ server.on('error', (err) => {
790
+ if (err.code === 'EADDRINUSE')
791
+ server.listen(preferredPort + 1, onListening);
792
+ else
793
+ reject(err);
794
+ });
795
+ server.on('close', () => {
796
+ unregisterProcessRuntime();
797
+ });
798
+ function onListening() {
799
+ const addr = server.address();
800
+ const actualPort = typeof addr === 'object' && addr ? addr.port : preferredPort;
801
+ const dashUrl = `http://localhost:${actualPort}`;
802
+ const ts = new Date().toTimeString().slice(0, 8);
803
+ process.stdout.write(`[pikiclaw ${ts}] dashboard: ${dashUrl}\n`);
804
+ if (opts.open !== false) {
805
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
806
+ exec(`${cmd} ${dashUrl}`);
807
+ }
808
+ resolve({
809
+ port: actualPort, url: dashUrl, server,
810
+ attachBot(bot) {
811
+ botRef = bot;
812
+ if (runtimePrefs.defaultAgent)
813
+ bot.setDefaultAgent(runtimePrefs.defaultAgent);
814
+ for (const [agent, model] of Object.entries(runtimePrefs.models)) {
815
+ if (isAgent(agent) && typeof model === 'string' && model.trim())
816
+ bot.setModelForAgent(agent, model);
817
+ }
818
+ for (const [agent, effort] of Object.entries(runtimePrefs.efforts)) {
819
+ if (isAgent(agent) && agent !== 'gemini' && typeof effort === 'string' && effort.trim())
820
+ bot.setEffortForAgent(agent, effort);
821
+ }
822
+ },
823
+ close() {
824
+ return new Promise(resolveClose => {
825
+ if (!server.listening) {
826
+ unregisterProcessRuntime();
827
+ resolveClose();
828
+ return;
829
+ }
830
+ server.close(() => {
831
+ unregisterProcessRuntime();
832
+ resolveClose();
833
+ });
834
+ });
835
+ },
836
+ });
837
+ }
838
+ server.listen(preferredPort, onListening);
839
+ });
840
+ }