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,838 @@
1
+ /**
2
+ * Dashboard API routes: session CRUD, workspace, streaming state.
3
+ */
4
+ import { Hono } from 'hono';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { loadUserConfig } from '../../core/config/user-config.js';
9
+ import { listAgents, listSkills, decodeAttachmentPathParam, resolveAllowedAttachmentPath, rewriteImageBlocksForTransport, } from '../../agent/index.js';
10
+ import { getSessionStatusForBot } from '../../bot/session-status.js';
11
+ import { findPikiloopSession } from '../../agent/session.js';
12
+ import { readAwaitResume } from '../../agent/await-resume.js';
13
+ import { cancelSessionTask, stopSessionTasks, getSessionStreamState, queueDashboardSessionTask, forkDashboardSessionTask, steerSessionTask, interactionSelectOption, interactionSubmitText, interactionSkip, interactionCancel, getInteractionPrompt, } from '../session-control.js';
14
+ import { querySessions, querySessionTail, querySessionMessages, getWorkspaceOverviews, updateSession, linkSessions, buildMigrationContext, exportSession, importSession, deleteSession, loadWorkspaces, addWorkspace, removeWorkspace, updateWorkspace, } from '../../bot/session-hub.js';
15
+ import { DASHBOARD_PAGINATION } from '../../core/constants.js';
16
+ import { runtime } from '../runtime.js';
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+ const DEFAULT_SESSION_PAGE_SIZE = DASHBOARD_PAGINATION.defaultPageSize;
21
+ const MAX_SESSION_PAGE_SIZE = DASHBOARD_PAGINATION.maxPageSize;
22
+ function parsePageNumber(value, fallback = 0) {
23
+ const parsed = Number.parseInt(value || '', 10);
24
+ if (!Number.isFinite(parsed) || parsed < 0)
25
+ return fallback;
26
+ return parsed;
27
+ }
28
+ function parsePageSize(value, fallback = DEFAULT_SESSION_PAGE_SIZE) {
29
+ const parsed = Number.parseInt(value || '', 10);
30
+ if (!Number.isFinite(parsed) || parsed <= 0)
31
+ return fallback;
32
+ return Math.min(parsed, MAX_SESSION_PAGE_SIZE);
33
+ }
34
+ function paginateSessionResult(items, page, limit) {
35
+ const total = items.length;
36
+ const totalPages = Math.max(1, Math.ceil(total / limit));
37
+ const safePage = Math.min(page, totalPages - 1);
38
+ const start = safePage * limit;
39
+ return {
40
+ sessions: items.slice(start, start + limit),
41
+ page: safePage,
42
+ limit,
43
+ total,
44
+ totalPages,
45
+ hasMore: safePage + 1 < totalPages,
46
+ };
47
+ }
48
+ function enrichWithRuntimeStatus(sessions, bot) {
49
+ return sessions.map(session => {
50
+ const status = bot ? getSessionStatusForBot(bot, session) : null;
51
+ const isRunning = status ? status.isRunning : !!session.running;
52
+ // "Waiting on background work" only applies to a session that isn't
53
+ // currently running — surface the marker the agent parked (if any) so the
54
+ // dashboard can show a distinct "waiting" state instead of "completed".
55
+ const awaiting = !isRunning && session.workdir && session.sessionId
56
+ ? readAwaitResume(session.workdir, session.agent, session.sessionId)
57
+ : null;
58
+ return {
59
+ ...session,
60
+ running: isRunning,
61
+ runState: isRunning ? 'running' : (session.runState === 'running' ? 'incomplete' : session.runState),
62
+ awaiting,
63
+ isCurrent: status?.isCurrent ?? false,
64
+ };
65
+ });
66
+ }
67
+ function readStringField(value) {
68
+ return typeof value === 'string' ? value.trim() : '';
69
+ }
70
+ function isUploadFile(value) {
71
+ return !!value
72
+ && typeof value === 'object'
73
+ && typeof value.arrayBuffer === 'function';
74
+ }
75
+ function extensionForMimeType(mimeType) {
76
+ switch (mimeType.toLowerCase()) {
77
+ case 'image/png': return '.png';
78
+ case 'image/jpeg': return '.jpg';
79
+ case 'image/webp': return '.webp';
80
+ case 'image/gif': return '.gif';
81
+ case 'image/svg+xml': return '.svg';
82
+ default: return '';
83
+ }
84
+ }
85
+ function sanitizeUploadFileName(rawName, mimeType, index) {
86
+ const baseName = path.basename(rawName || `attachment-${index + 1}`);
87
+ const parsed = path.parse(baseName);
88
+ const safeStem = (parsed.name || `attachment-${index + 1}`)
89
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
90
+ .replace(/^-+|-+$/g, '')
91
+ || `attachment-${index + 1}`;
92
+ const ext = parsed.ext || extensionForMimeType(mimeType) || '.bin';
93
+ return `${safeStem}${ext.toLowerCase()}`;
94
+ }
95
+ async function materializeUploadedFiles(entries) {
96
+ const files = entries.filter(isUploadFile);
97
+ if (!files.length) {
98
+ return { attachments: [], cleanup: async () => { } };
99
+ }
100
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pikiloop-dashboard-upload-'));
101
+ try {
102
+ const attachments = [];
103
+ for (const [index, file] of files.entries()) {
104
+ const filename = sanitizeUploadFileName(String(file.name || ''), String(file.type || ''), index);
105
+ const filePath = path.join(tempDir, filename);
106
+ await fs.promises.writeFile(filePath, Buffer.from(await file.arrayBuffer()));
107
+ attachments.push(filePath);
108
+ }
109
+ return {
110
+ attachments,
111
+ cleanup: async () => {
112
+ await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => { });
113
+ },
114
+ };
115
+ }
116
+ catch (error) {
117
+ await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => { });
118
+ throw error;
119
+ }
120
+ }
121
+ async function parseSessionSendRequest(c) {
122
+ const contentType = String(c.req.header('content-type') || '').toLowerCase();
123
+ if (contentType.includes('multipart/form-data')) {
124
+ const form = await c.req.formData();
125
+ const uploads = await materializeUploadedFiles(form.getAll('attachments'));
126
+ return {
127
+ workdir: readStringField(form.get('workdir')),
128
+ agent: readStringField(form.get('agent')),
129
+ sessionId: readStringField(form.get('sessionId')),
130
+ prompt: readStringField(form.get('prompt')),
131
+ model: readStringField(form.get('model')),
132
+ effort: readStringField(form.get('effort')).toLowerCase(),
133
+ workflow: readStringField(form.get('workflow')) === '1',
134
+ attachments: uploads.attachments,
135
+ previousAgent: readStringField(form.get('previousAgent')),
136
+ previousSessionId: readStringField(form.get('previousSessionId')),
137
+ cleanup: uploads.cleanup,
138
+ };
139
+ }
140
+ const body = await c.req.json();
141
+ return {
142
+ workdir: readStringField(body?.workdir),
143
+ agent: readStringField(body?.agent),
144
+ sessionId: readStringField(body?.sessionId),
145
+ prompt: readStringField(body?.prompt),
146
+ model: readStringField(body?.model),
147
+ effort: readStringField(body?.effort).toLowerCase(),
148
+ workflow: body?.workflow === true,
149
+ attachments: [],
150
+ previousAgent: readStringField(body?.previousAgent),
151
+ previousSessionId: readStringField(body?.previousSessionId),
152
+ cleanup: async () => { },
153
+ };
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // Routes
157
+ // ---------------------------------------------------------------------------
158
+ const app = new Hono();
159
+ // ==========================================================================
160
+ // Legacy session routes (backward-compat for dashboard-ui)
161
+ // ==========================================================================
162
+ // Sessions per agent: GET /api/sessions/:agent
163
+ app.get('/api/sessions/:agent', async (c) => {
164
+ const agent = c.req.param('agent');
165
+ const config = loadUserConfig();
166
+ const workdir = runtime.getRequestWorkdir(config);
167
+ const page = parsePageNumber(c.req.query('page'));
168
+ const limit = parsePageSize(c.req.query('limit'));
169
+ const botRef = runtime.getBotRef();
170
+ runtime.debug(`[sessions] endpoint=single agent=${agent} resolvedWorkdir=${workdir} exists=${fs.existsSync(workdir)} ` +
171
+ `page=${page} limit=${limit}`);
172
+ const result = await querySessions({ workdir, agent });
173
+ const enriched = enrichWithRuntimeStatus(result.sessions, botRef);
174
+ const paged = paginateSessionResult(enriched, page, limit);
175
+ runtime.debug(`[sessions] endpoint=single agent=${agent} ok=${result.ok} total=${result.total} ` +
176
+ `returned=${paged.sessions.length} error=${result.errors.join('; ') || '(none)'}`);
177
+ return c.json({
178
+ ok: result.ok,
179
+ error: result.errors[0] || null,
180
+ ...paged,
181
+ });
182
+ });
183
+ // All sessions (swim lane): GET /api/sessions
184
+ app.get('/api/sessions', async (c) => {
185
+ const config = loadUserConfig();
186
+ const workdir = runtime.getRequestWorkdir(config);
187
+ const page = parsePageNumber(c.req.query('page'));
188
+ const limit = parsePageSize(c.req.query('limit'));
189
+ const botRef = runtime.getBotRef();
190
+ runtime.debug(`[sessions] endpoint=all resolvedWorkdir=${workdir} exists=${fs.existsSync(workdir)} ` +
191
+ `page=${page} limit=${limit}`);
192
+ const agents = listAgents().agents.filter(a => a.installed);
193
+ const swimLane = {};
194
+ await Promise.all(agents.map(async (a) => {
195
+ const result = await querySessions({ workdir, agent: a.agent });
196
+ const enriched = enrichWithRuntimeStatus(result.sessions, botRef);
197
+ const paged = paginateSessionResult(enriched, page, limit);
198
+ swimLane[a.agent] = {
199
+ ok: result.ok,
200
+ error: result.errors[0] || null,
201
+ ...paged,
202
+ };
203
+ runtime.debug(`[sessions] endpoint=all agent=${a.agent} ok=${result.ok} total=${result.total} ` +
204
+ `returned=${paged.sessions.length} error=${result.errors.join('; ') || '(none)'}`);
205
+ }));
206
+ return c.json(swimLane);
207
+ });
208
+ // Session detail (tail): GET /api/session-detail/:agent/:id
209
+ app.get('/api/session-detail/:agent/:id', async (c) => {
210
+ const agent = c.req.param('agent');
211
+ const sessionId = decodeURIComponent(c.req.param('id'));
212
+ const config = loadUserConfig();
213
+ const workdir = runtime.getRequestWorkdir(config);
214
+ const limit = parseInt(c.req.query('limit') || '6', 10);
215
+ runtime.debug(`[sessions] endpoint=detail agent=${agent} session=${sessionId} limit=${limit} resolvedWorkdir=${workdir} ` +
216
+ `exists=${fs.existsSync(workdir)}`);
217
+ const tail = await querySessionTail({ agent, sessionId, workdir, limit });
218
+ runtime.debug(`[sessions] endpoint=detail agent=${agent} session=${sessionId} ok=${tail.ok} ` +
219
+ `messages=${tail.messages.length} error=${tail.error || '(none)'}`);
220
+ return c.json(tail);
221
+ });
222
+ // ==========================================================================
223
+ // Workspace CRUD
224
+ // ==========================================================================
225
+ app.get('/api/workspaces', (c) => {
226
+ const workspaces = loadWorkspaces();
227
+ // Always include the current runtimeWorkdir, deduplicating by path
228
+ const config = loadUserConfig();
229
+ const rwd = runtime.getRuntimeWorkdir(config);
230
+ if (rwd && !workspaces.some(w => w.path === rwd)) {
231
+ workspaces.unshift({
232
+ path: rwd,
233
+ name: path.basename(rwd),
234
+ order: -1,
235
+ addedAt: new Date().toISOString(),
236
+ });
237
+ }
238
+ return c.json({ ok: true, workspaces });
239
+ });
240
+ app.post('/api/workspaces', async (c) => {
241
+ try {
242
+ const body = await c.req.json();
243
+ const wsPath = typeof body?.path === 'string' ? body.path.trim() : '';
244
+ if (!wsPath)
245
+ return c.json({ ok: false, error: 'path is required' }, 400);
246
+ const entry = addWorkspace(wsPath, body?.name);
247
+ return c.json({ ok: true, workspace: entry });
248
+ }
249
+ catch (e) {
250
+ return c.json({ ok: false, error: e.message }, 500);
251
+ }
252
+ });
253
+ app.delete('/api/workspaces', async (c) => {
254
+ try {
255
+ const body = await c.req.json();
256
+ const wsPath = typeof body?.path === 'string' ? body.path.trim() : '';
257
+ if (!wsPath)
258
+ return c.json({ ok: false, error: 'path is required' }, 400);
259
+ const removed = removeWorkspace(wsPath);
260
+ return c.json({ ok: true, removed });
261
+ }
262
+ catch (e) {
263
+ return c.json({ ok: false, error: e.message }, 500);
264
+ }
265
+ });
266
+ app.patch('/api/workspaces', async (c) => {
267
+ try {
268
+ const body = await c.req.json();
269
+ const wsPath = typeof body?.path === 'string' ? body.path.trim() : '';
270
+ if (!wsPath)
271
+ return c.json({ ok: false, error: 'path is required' }, 400);
272
+ const updated = updateWorkspace(wsPath, body);
273
+ return c.json({ ok: true, workspace: updated });
274
+ }
275
+ catch (e) {
276
+ return c.json({ ok: false, error: e.message }, 500);
277
+ }
278
+ });
279
+ // ==========================================================================
280
+ // Workspace overviews
281
+ // ==========================================================================
282
+ app.get('/api/workspace-overviews', async (c) => {
283
+ try {
284
+ const overviews = await getWorkspaceOverviews();
285
+ return c.json({ ok: true, overviews });
286
+ }
287
+ catch (e) {
288
+ return c.json({ ok: false, error: e.message }, 500);
289
+ }
290
+ });
291
+ // ==========================================================================
292
+ // Session hub operations
293
+ // ==========================================================================
294
+ app.post('/api/session-hub/sessions', async (c) => {
295
+ try {
296
+ const body = await c.req.json();
297
+ const workdir = typeof body?.workdir === 'string' ? body.workdir.trim() : '';
298
+ if (!workdir)
299
+ return c.json({ ok: false, error: 'workdir is required' }, 400);
300
+ const botRef = runtime.getBotRef();
301
+ const result = await querySessions({
302
+ workdir,
303
+ agent: body?.agents,
304
+ userStatus: body?.userStatus,
305
+ limit: body?.limit,
306
+ });
307
+ return c.json({
308
+ ...result,
309
+ sessions: enrichWithRuntimeStatus(result.sessions, botRef),
310
+ });
311
+ }
312
+ catch (e) {
313
+ return c.json({ ok: false, error: e.message }, 500);
314
+ }
315
+ });
316
+ app.post('/api/session-hub/session/status', async (c) => {
317
+ try {
318
+ const body = await c.req.json();
319
+ const { workdir, agent, sessionId, status } = body || {};
320
+ if (!workdir || !agent || !sessionId || !status) {
321
+ return c.json({ ok: false, error: 'workdir, agent, sessionId, and status are required' }, 400);
322
+ }
323
+ const updated = updateSession(workdir, agent, sessionId, { userStatus: status });
324
+ return c.json({ ok: true, updated });
325
+ }
326
+ catch (e) {
327
+ return c.json({ ok: false, error: e.message }, 500);
328
+ }
329
+ });
330
+ app.post('/api/session-hub/session/note', async (c) => {
331
+ try {
332
+ const body = await c.req.json();
333
+ const { workdir, agent, sessionId, note } = body || {};
334
+ if (!workdir || !agent || !sessionId) {
335
+ return c.json({ ok: false, error: 'workdir, agent, and sessionId are required' }, 400);
336
+ }
337
+ const updated = updateSession(workdir, agent, sessionId, { userNote: note ?? null });
338
+ return c.json({ ok: true, updated });
339
+ }
340
+ catch (e) {
341
+ return c.json({ ok: false, error: e.message }, 500);
342
+ }
343
+ });
344
+ app.post('/api/session-hub/session/delete', async (c) => {
345
+ try {
346
+ const body = await c.req.json();
347
+ const workdir = typeof body?.workdir === 'string' ? body.workdir.trim() : '';
348
+ const agent = typeof body?.agent === 'string' ? body.agent.trim() : '';
349
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : '';
350
+ const purgeNative = body?.purgeNative === true;
351
+ if (!workdir || !agent || !sessionId) {
352
+ return c.json({ ok: false, error: 'workdir, agent, and sessionId are required' }, 400);
353
+ }
354
+ if (!runtime.isAgent(agent)) {
355
+ return c.json({ ok: false, error: `Unknown agent: ${agent}` }, 400);
356
+ }
357
+ runtime.debug(`[sessions] endpoint=delete agent=${agent} session=${sessionId} workdir=${workdir} purgeNative=${purgeNative}`);
358
+ const result = await deleteSession({ workdir, agent: agent, sessionId, purgeNative });
359
+ if (result.refusedReason === 'session-running') {
360
+ return c.json({ ok: false, error: 'session is still running — stop it first' }, 409);
361
+ }
362
+ return c.json({
363
+ ok: true,
364
+ recordRemoved: result.recordRemoved,
365
+ pikiloopPathsRemoved: result.pikiloopPathsRemoved,
366
+ nativePathsRemoved: result.nativePathsRemoved,
367
+ });
368
+ }
369
+ catch (e) {
370
+ return c.json({ ok: false, error: e.message }, 500);
371
+ }
372
+ });
373
+ app.post('/api/session-hub/session/link', async (c) => {
374
+ try {
375
+ const body = await c.req.json();
376
+ if (!body?.a || !body?.b || !body?.workdir) {
377
+ return c.json({ ok: false, error: 'workdir, a: {agent, sessionId}, b: {agent, sessionId} required' }, 400);
378
+ }
379
+ const linked = linkSessions(body.workdir, body.a, body.b);
380
+ return c.json({ ok: true, linked });
381
+ }
382
+ catch (e) {
383
+ return c.json({ ok: false, error: e.message }, 500);
384
+ }
385
+ });
386
+ app.post('/api/session-hub/session/messages', async (c) => {
387
+ try {
388
+ const body = await c.req.json();
389
+ const { workdir, agent, sessionId, lastNTurns, turnOffset, turnLimit } = body || {};
390
+ if (!workdir || !agent || !sessionId) {
391
+ return c.json({ ok: false, error: 'workdir, agent, and sessionId are required' }, 400);
392
+ }
393
+ const rich = body?.rich !== false;
394
+ const result = await querySessionMessages({
395
+ agent,
396
+ sessionId,
397
+ workdir,
398
+ lastNTurns: Number.isFinite(lastNTurns) ? lastNTurns : undefined,
399
+ turnOffset: Number.isFinite(turnOffset) ? turnOffset : undefined,
400
+ turnLimit: Number.isFinite(turnLimit) ? turnLimit : undefined,
401
+ rich,
402
+ });
403
+ return c.json(rewriteSessionImagesForDashboard(result, agent, sessionId));
404
+ }
405
+ catch (e) {
406
+ return c.json({ ok: false, error: e.message }, 500);
407
+ }
408
+ });
409
+ // Rewrite oversized inline image data URLs into attachment HTTP URLs so
410
+ // dashboard JSON payloads stay compact. Small inline images pass through.
411
+ function rewriteSessionImagesForDashboard(result, agent, sessionId) {
412
+ if (!result.richMessages?.length)
413
+ return result;
414
+ const richMessages = result.richMessages.map(message => ({
415
+ ...message,
416
+ blocks: rewriteImageBlocksForTransport(message.blocks, { agent, sessionId }),
417
+ }));
418
+ return { ...result, richMessages };
419
+ }
420
+ // Attachment endpoint — serves on-disk images referenced by RichMessage image
421
+ // blocks via opaque base64url path tokens. The allowlist (see images.ts)
422
+ // confines reads to a known set of agent-managed dirs + the session's workdir.
423
+ app.get('/api/sessions/:agent/:id/attachment', async (c) => {
424
+ const agent = c.req.param('agent');
425
+ const sessionId = decodeURIComponent(c.req.param('id'));
426
+ const token = c.req.query('p') || '';
427
+ if (!token)
428
+ return c.json({ ok: false, error: 'missing path parameter' }, 400);
429
+ let requestedPath;
430
+ try {
431
+ requestedPath = decodeAttachmentPathParam(token);
432
+ }
433
+ catch {
434
+ return c.json({ ok: false, error: 'invalid path token' }, 400);
435
+ }
436
+ if (!requestedPath || requestedPath.includes('\0')) {
437
+ return c.json({ ok: false, error: 'invalid path' }, 400);
438
+ }
439
+ // Widen the allowlist with the session's recorded workdir when known —
440
+ // images generated under the project tree resolve cleanly. Session indexes
441
+ // are per-workdir and this URL carries no workdir, so a lookup against the
442
+ // runtime workdir alone misses sessions living in any OTHER registered
443
+ // workspace (the Session Hub renders all of them through this endpoint) —
444
+ // their user-attached images 403'd as "broken image" in the dashboard.
445
+ // Registered workspace roots come from server-side config, never request
446
+ // input, so widening to all of them keeps the same trust boundary and also
447
+ // covers the pending→native id promotion window where no index has the
448
+ // session yet.
449
+ const config = loadUserConfig();
450
+ const fallbackWorkdir = runtime.getRequestWorkdir(config);
451
+ const managed = findPikiloopSession(fallbackWorkdir, agent, sessionId);
452
+ const workdirs = [
453
+ ...(managed?.workdir ? [managed.workdir] : []),
454
+ fallbackWorkdir,
455
+ ...loadWorkspaces().map(ws => ws.path),
456
+ ];
457
+ const resolved = resolveAllowedAttachmentPath(requestedPath, workdirs);
458
+ if (!resolved)
459
+ return c.json({ ok: false, error: 'forbidden' }, 403);
460
+ let stat;
461
+ try {
462
+ stat = fs.statSync(resolved);
463
+ }
464
+ catch {
465
+ return c.json({ ok: false, error: 'not found' }, 404);
466
+ }
467
+ if (!stat.isFile())
468
+ return c.json({ ok: false, error: 'not a file' }, 400);
469
+ const ext = path.extname(resolved).toLowerCase();
470
+ const mime = mimeForExtFallback(ext);
471
+ const bytes = await fs.promises.readFile(resolved);
472
+ // The path is hash-immutable for agent-managed dirs (`ig_<sha>.png`, …) and
473
+ // the session lifecycle keeps the file stable — long cache is safe.
474
+ return c.body(bytes, 200, {
475
+ 'Content-Type': mime,
476
+ 'Content-Length': String(bytes.length),
477
+ 'Cache-Control': 'private, max-age=31536000, immutable',
478
+ 'X-Content-Type-Options': 'nosniff',
479
+ });
480
+ });
481
+ function mimeForExtFallback(ext) {
482
+ switch (ext.toLowerCase()) {
483
+ case '.png': return 'image/png';
484
+ case '.jpg':
485
+ case '.jpeg': return 'image/jpeg';
486
+ case '.gif': return 'image/gif';
487
+ case '.webp': return 'image/webp';
488
+ case '.svg': return 'image/svg+xml';
489
+ default: return 'application/octet-stream';
490
+ }
491
+ }
492
+ app.post('/api/session-hub/migrate', async (c) => {
493
+ try {
494
+ const body = await c.req.json();
495
+ if (!body?.source || !body?.target) {
496
+ return c.json({ ok: false, error: 'source and target are required' }, 400);
497
+ }
498
+ const result = await buildMigrationContext({
499
+ source: body.source,
500
+ target: body.target,
501
+ lastNTurns: body.lastNTurns,
502
+ });
503
+ return c.json(result);
504
+ }
505
+ catch (e) {
506
+ return c.json({ ok: false, error: e.message }, 500);
507
+ }
508
+ });
509
+ app.post('/api/session-hub/export', async (c) => {
510
+ try {
511
+ const body = await c.req.json();
512
+ if (!body?.workdir || !body?.agent || !body?.sessionId) {
513
+ return c.json({ ok: false, error: 'workdir, agent, sessionId are required' }, 400);
514
+ }
515
+ const result = await exportSession({
516
+ workdir: body.workdir,
517
+ agent: body.agent,
518
+ sessionId: body.sessionId,
519
+ format: body.format || 'markdown',
520
+ lastNTurns: body.lastNTurns,
521
+ });
522
+ return c.json(result);
523
+ }
524
+ catch (e) {
525
+ return c.json({ ok: false, error: e.message }, 500);
526
+ }
527
+ });
528
+ app.post('/api/session-hub/import', async (c) => {
529
+ try {
530
+ const body = await c.req.json();
531
+ if (!body?.workdir || !body?.agent || !body?.content) {
532
+ return c.json({ ok: false, error: 'workdir, agent, and content are required' }, 400);
533
+ }
534
+ const result = importSession({
535
+ workdir: body.workdir,
536
+ agent: body.agent,
537
+ content: body.content,
538
+ format: body.format,
539
+ });
540
+ return c.json(result);
541
+ }
542
+ catch (e) {
543
+ return c.json({ ok: false, error: e.message }, 500);
544
+ }
545
+ });
546
+ // ==========================================================================
547
+ // Skills
548
+ // ==========================================================================
549
+ app.get('/api/session-hub/skills', (c) => {
550
+ const workdir = c.req.query('workdir') || '';
551
+ if (!workdir)
552
+ return c.json({ ok: false, error: 'workdir query param required' }, 400);
553
+ try {
554
+ const result = listSkills(workdir);
555
+ return c.json({ ok: true, skills: result.skills });
556
+ }
557
+ catch (e) {
558
+ return c.json({ ok: false, skills: [], error: e.message }, 500);
559
+ }
560
+ });
561
+ // ==========================================================================
562
+ // Session interaction (send / recall / steer / stream)
563
+ // ==========================================================================
564
+ app.post('/api/session-hub/session/send', async (c) => {
565
+ try {
566
+ const { workdir, agent, sessionId, prompt, model, effort, workflow, attachments, previousAgent, previousSessionId, cleanup } = await parseSessionSendRequest(c);
567
+ const queued = await queueDashboardSessionTask({
568
+ workdir,
569
+ agent,
570
+ sessionId,
571
+ prompt,
572
+ model,
573
+ effort,
574
+ workflow,
575
+ attachments,
576
+ previousAgent: previousAgent || null,
577
+ previousSessionId: previousSessionId || null,
578
+ });
579
+ await cleanup();
580
+ if (!queued.ok) {
581
+ const status = queued.error === 'Bot is not running' ? 503 : 400;
582
+ return c.json(queued, status);
583
+ }
584
+ runtime.debug(`[session-send] queued task=${queued.taskId} session=${queued.sessionKey} attachments=${attachments.length} ` +
585
+ `prompt="${(prompt || '[attachments only]').slice(0, 80)}"`);
586
+ return c.json(queued);
587
+ }
588
+ catch (e) {
589
+ return c.json({ ok: false, error: e.message }, 500);
590
+ }
591
+ });
592
+ // Polling endpoint: GET /api/session-hub/session/stream-state?agent=X&sessionId=Y
593
+ app.get('/api/session-hub/session/stream-state', (c) => {
594
+ const agent = c.req.query('agent') || '';
595
+ const sessionId = c.req.query('sessionId') || '';
596
+ if (!agent || !sessionId) {
597
+ return c.json({ ok: false, error: 'agent and sessionId query params required' }, 400);
598
+ }
599
+ return c.json(getSessionStreamState(agent, sessionId));
600
+ });
601
+ // Fork: branch off a parent session at `atTurn`, queue the new prompt against
602
+ // the freshly forked child. Returns the queued task + the pending child session
603
+ // key so the dashboard can navigate the user into the child immediately.
604
+ app.post('/api/session-hub/session/fork', async (c) => {
605
+ try {
606
+ const body = await c.req.json();
607
+ const { workdir, agent, sessionId, atTurn, prompt, model, effort, attachments } = body || {};
608
+ if (!workdir || !agent || !sessionId || typeof atTurn !== 'number' || !prompt) {
609
+ return c.json({ ok: false, error: 'workdir, agent, sessionId, atTurn (number), and prompt are required' }, 400);
610
+ }
611
+ const queued = forkDashboardSessionTask({
612
+ workdir,
613
+ agent,
614
+ parentSessionId: sessionId,
615
+ atTurn,
616
+ prompt,
617
+ model: model || null,
618
+ effort: effort || null,
619
+ attachments: Array.isArray(attachments) ? attachments : [],
620
+ });
621
+ if (!queued.ok) {
622
+ const status = queued.error === 'Bot is not running' ? 503 : 400;
623
+ return c.json(queued, status);
624
+ }
625
+ runtime.debug(`[session-fork] queued task=${queued.taskId} parent=${agent}:${sessionId} child=${queued.sessionKey} atTurn=${atTurn}`);
626
+ return c.json(queued);
627
+ }
628
+ catch (e) {
629
+ return c.json({ ok: false, error: e.message }, 500);
630
+ }
631
+ });
632
+ app.post('/api/session-hub/session/recall', async (c) => {
633
+ try {
634
+ const body = await c.req.json();
635
+ const { taskId } = body || {};
636
+ if (!taskId) {
637
+ return c.json({ ok: false, error: 'taskId is required' }, 400);
638
+ }
639
+ const result = cancelSessionTask(taskId);
640
+ return c.json(result, result.ok ? 200 : 503);
641
+ }
642
+ catch (e) {
643
+ return c.json({ ok: false, error: e.message }, 500);
644
+ }
645
+ });
646
+ // Stop only the currently running stream for a session — queued follow-ups
647
+ // are kept so they run next. Takes (agent, sessionId) rather than taskId so it
648
+ // works during the moment after a fresh send where the client hasn't yet
649
+ // learned the streamTaskId.
650
+ app.post('/api/session-hub/session/stop', async (c) => {
651
+ try {
652
+ const body = await c.req.json();
653
+ const { agent, sessionId } = body || {};
654
+ if (!agent || !sessionId) {
655
+ return c.json({ ok: false, error: 'agent and sessionId are required' }, 400);
656
+ }
657
+ const result = stopSessionTasks(agent, sessionId);
658
+ return c.json(result, result.ok ? 200 : 503);
659
+ }
660
+ catch (e) {
661
+ return c.json({ ok: false, error: e.message }, 500);
662
+ }
663
+ });
664
+ app.post('/api/session-hub/session/steer', async (c) => {
665
+ try {
666
+ const body = await c.req.json();
667
+ const { taskId } = body || {};
668
+ if (!taskId) {
669
+ return c.json({ ok: false, error: 'taskId is required' }, 400);
670
+ }
671
+ const result = await steerSessionTask(taskId);
672
+ return c.json(result, result.ok ? 200 : 503);
673
+ }
674
+ catch (e) {
675
+ return c.json({ ok: false, error: e.message }, 500);
676
+ }
677
+ });
678
+ // ==========================================================================
679
+ // Persistent thread goal (analogous to Codex CLI's `/goal`).
680
+ // ==========================================================================
681
+ app.get('/api/session-hub/session/goal', async (c) => {
682
+ const workdir = c.req.query('workdir') || '';
683
+ const agent = c.req.query('agent') || '';
684
+ const sessionId = c.req.query('sessionId') || '';
685
+ if (!workdir || !agent || !sessionId) {
686
+ return c.json({ ok: false, error: 'workdir, agent, and sessionId query params required' }, 400);
687
+ }
688
+ const bot = runtime.getBotRef();
689
+ if (!bot)
690
+ return c.json({ ok: false, error: 'bot not attached' }, 503);
691
+ try {
692
+ const goal = await bot.getSessionGoal(workdir, agent, sessionId);
693
+ return c.json({ ok: true, goal });
694
+ }
695
+ catch (e) {
696
+ return c.json({ ok: false, error: e.message }, 500);
697
+ }
698
+ });
699
+ app.post('/api/session-hub/session/goal', async (c) => {
700
+ try {
701
+ const body = await c.req.json();
702
+ const { workdir, agent, sessionId, objective, tokenBudget, modelId, thinkingEffort } = body || {};
703
+ if (!workdir || !agent || !sessionId || typeof objective !== 'string' || !objective.trim()) {
704
+ return c.json({ ok: false, error: 'workdir, agent, sessionId, and objective are required' }, 400);
705
+ }
706
+ const bot = runtime.getBotRef();
707
+ if (!bot)
708
+ return c.json({ ok: false, error: 'bot not attached' }, 503);
709
+ const goal = await bot.setSessionGoal(workdir, agent, sessionId, {
710
+ objective,
711
+ tokenBudget: typeof tokenBudget === 'number' ? tokenBudget : null,
712
+ modelId: typeof modelId === 'string' ? modelId : undefined,
713
+ thinkingEffort: typeof thinkingEffort === 'string' ? thinkingEffort : undefined,
714
+ });
715
+ return c.json({ ok: true, goal });
716
+ }
717
+ catch (e) {
718
+ return c.json({ ok: false, error: e.message }, 500);
719
+ }
720
+ });
721
+ app.post('/api/session-hub/session/goal/pause', async (c) => {
722
+ try {
723
+ const body = await c.req.json();
724
+ const { workdir, agent, sessionId } = body || {};
725
+ if (!workdir || !agent || !sessionId) {
726
+ return c.json({ ok: false, error: 'workdir, agent, and sessionId are required' }, 400);
727
+ }
728
+ const bot = runtime.getBotRef();
729
+ if (!bot)
730
+ return c.json({ ok: false, error: 'bot not attached' }, 503);
731
+ const goal = await bot.pauseSessionGoal(workdir, agent, sessionId);
732
+ return c.json({ ok: true, goal });
733
+ }
734
+ catch (e) {
735
+ return c.json({ ok: false, error: e.message }, 500);
736
+ }
737
+ });
738
+ app.post('/api/session-hub/session/goal/resume', async (c) => {
739
+ try {
740
+ const body = await c.req.json();
741
+ const { workdir, agent, sessionId, modelId, thinkingEffort } = body || {};
742
+ if (!workdir || !agent || !sessionId) {
743
+ return c.json({ ok: false, error: 'workdir, agent, and sessionId are required' }, 400);
744
+ }
745
+ const bot = runtime.getBotRef();
746
+ if (!bot)
747
+ return c.json({ ok: false, error: 'bot not attached' }, 503);
748
+ const goal = await bot.resumeSessionGoal(workdir, agent, sessionId, {
749
+ modelId: typeof modelId === 'string' ? modelId : undefined,
750
+ thinkingEffort: typeof thinkingEffort === 'string' ? thinkingEffort : undefined,
751
+ });
752
+ return c.json({ ok: true, goal });
753
+ }
754
+ catch (e) {
755
+ return c.json({ ok: false, error: e.message }, 500);
756
+ }
757
+ });
758
+ app.post('/api/session-hub/session/goal/clear', async (c) => {
759
+ try {
760
+ const body = await c.req.json();
761
+ const { workdir, agent, sessionId } = body || {};
762
+ if (!workdir || !agent || !sessionId) {
763
+ return c.json({ ok: false, error: 'workdir, agent, and sessionId are required' }, 400);
764
+ }
765
+ const bot = runtime.getBotRef();
766
+ if (!bot)
767
+ return c.json({ ok: false, error: 'bot not attached' }, 503);
768
+ await bot.clearSessionGoal(workdir, agent, sessionId);
769
+ return c.json({ ok: true });
770
+ }
771
+ catch (e) {
772
+ return c.json({ ok: false, error: e.message }, 500);
773
+ }
774
+ });
775
+ // ==========================================================================
776
+ // Interaction prompts (human-in-the-loop)
777
+ // ==========================================================================
778
+ /** GET /api/interaction/:promptId — Get interaction prompt state. */
779
+ app.get('/api/interaction/:promptId', (c) => {
780
+ const { promptId } = c.req.param();
781
+ const result = getInteractionPrompt(promptId);
782
+ return c.json(result, result.ok ? 200 : 503);
783
+ });
784
+ /** POST /api/interaction/:promptId/select — Select an option. */
785
+ app.post('/api/interaction/:promptId/select', async (c) => {
786
+ try {
787
+ const { promptId } = c.req.param();
788
+ const body = await c.req.json();
789
+ const { value, requestFreeform } = body || {};
790
+ if (!value && !requestFreeform) {
791
+ return c.json({ ok: false, error: 'value is required' }, 400);
792
+ }
793
+ const result = interactionSelectOption(promptId, value || '__other__', { requestFreeform: !!requestFreeform });
794
+ return c.json(result, result.ok ? 200 : (result.error === 'Bot is not running' ? 503 : 404));
795
+ }
796
+ catch (e) {
797
+ return c.json({ ok: false, error: e.message }, 500);
798
+ }
799
+ });
800
+ /** POST /api/interaction/:promptId/text — Submit freeform text. */
801
+ app.post('/api/interaction/:promptId/text', async (c) => {
802
+ try {
803
+ const { promptId } = c.req.param();
804
+ const body = await c.req.json();
805
+ const { text } = body || {};
806
+ if (typeof text !== 'string') {
807
+ return c.json({ ok: false, error: 'text is required' }, 400);
808
+ }
809
+ const result = interactionSubmitText(promptId, text);
810
+ return c.json(result, result.ok ? 200 : (result.error === 'Bot is not running' ? 503 : 404));
811
+ }
812
+ catch (e) {
813
+ return c.json({ ok: false, error: e.message }, 500);
814
+ }
815
+ });
816
+ /** POST /api/interaction/:promptId/skip — Skip current question. */
817
+ app.post('/api/interaction/:promptId/skip', async (c) => {
818
+ try {
819
+ const { promptId } = c.req.param();
820
+ const result = interactionSkip(promptId);
821
+ return c.json(result, result.ok ? 200 : (result.error === 'Bot is not running' ? 503 : 404));
822
+ }
823
+ catch (e) {
824
+ return c.json({ ok: false, error: e.message }, 500);
825
+ }
826
+ });
827
+ /** POST /api/interaction/:promptId/cancel — Cancel interaction prompt. */
828
+ app.post('/api/interaction/:promptId/cancel', async (c) => {
829
+ try {
830
+ const { promptId } = c.req.param();
831
+ const result = interactionCancel(promptId);
832
+ return c.json(result, result.ok ? 200 : (result.error === 'Bot is not running' ? 503 : 404));
833
+ }
834
+ catch (e) {
835
+ return c.json({ ok: false, error: e.message }, 500);
836
+ }
837
+ });
838
+ export default app;