@ottocode/server 0.1.173

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 (111) hide show
  1. package/package.json +42 -0
  2. package/src/events/bus.ts +43 -0
  3. package/src/events/types.ts +32 -0
  4. package/src/index.ts +281 -0
  5. package/src/openapi/helpers.ts +64 -0
  6. package/src/openapi/paths/ask.ts +70 -0
  7. package/src/openapi/paths/config.ts +218 -0
  8. package/src/openapi/paths/files.ts +72 -0
  9. package/src/openapi/paths/git.ts +457 -0
  10. package/src/openapi/paths/messages.ts +92 -0
  11. package/src/openapi/paths/sessions.ts +90 -0
  12. package/src/openapi/paths/setu.ts +154 -0
  13. package/src/openapi/paths/stream.ts +26 -0
  14. package/src/openapi/paths/terminals.ts +226 -0
  15. package/src/openapi/schemas.ts +345 -0
  16. package/src/openapi/spec.ts +49 -0
  17. package/src/presets.ts +85 -0
  18. package/src/routes/ask.ts +113 -0
  19. package/src/routes/auth.ts +592 -0
  20. package/src/routes/branch.ts +106 -0
  21. package/src/routes/config/agents.ts +44 -0
  22. package/src/routes/config/cwd.ts +21 -0
  23. package/src/routes/config/defaults.ts +45 -0
  24. package/src/routes/config/index.ts +16 -0
  25. package/src/routes/config/main.ts +73 -0
  26. package/src/routes/config/models.ts +139 -0
  27. package/src/routes/config/providers.ts +46 -0
  28. package/src/routes/config/utils.ts +120 -0
  29. package/src/routes/files.ts +218 -0
  30. package/src/routes/git/branch.ts +75 -0
  31. package/src/routes/git/commit.ts +209 -0
  32. package/src/routes/git/diff.ts +137 -0
  33. package/src/routes/git/index.ts +18 -0
  34. package/src/routes/git/push.ts +160 -0
  35. package/src/routes/git/schemas.ts +48 -0
  36. package/src/routes/git/staging.ts +208 -0
  37. package/src/routes/git/status.ts +83 -0
  38. package/src/routes/git/types.ts +31 -0
  39. package/src/routes/git/utils.ts +249 -0
  40. package/src/routes/openapi.ts +6 -0
  41. package/src/routes/research.ts +392 -0
  42. package/src/routes/root.ts +5 -0
  43. package/src/routes/session-approval.ts +63 -0
  44. package/src/routes/session-files.ts +387 -0
  45. package/src/routes/session-messages.ts +170 -0
  46. package/src/routes/session-stream.ts +61 -0
  47. package/src/routes/sessions.ts +814 -0
  48. package/src/routes/setu.ts +346 -0
  49. package/src/routes/terminals.ts +227 -0
  50. package/src/runtime/agent/registry.ts +351 -0
  51. package/src/runtime/agent/runner-reasoning.ts +108 -0
  52. package/src/runtime/agent/runner-setup.ts +257 -0
  53. package/src/runtime/agent/runner.ts +375 -0
  54. package/src/runtime/agent-registry.ts +6 -0
  55. package/src/runtime/ask/service.ts +369 -0
  56. package/src/runtime/context/environment.ts +202 -0
  57. package/src/runtime/debug/index.ts +117 -0
  58. package/src/runtime/debug/state.ts +140 -0
  59. package/src/runtime/errors/api-error.ts +192 -0
  60. package/src/runtime/errors/handling.ts +199 -0
  61. package/src/runtime/message/compaction-auto.ts +154 -0
  62. package/src/runtime/message/compaction-context.ts +101 -0
  63. package/src/runtime/message/compaction-detect.ts +26 -0
  64. package/src/runtime/message/compaction-limits.ts +37 -0
  65. package/src/runtime/message/compaction-mark.ts +111 -0
  66. package/src/runtime/message/compaction-prune.ts +75 -0
  67. package/src/runtime/message/compaction.ts +21 -0
  68. package/src/runtime/message/history-builder.ts +266 -0
  69. package/src/runtime/message/service.ts +468 -0
  70. package/src/runtime/message/tool-history-tracker.ts +204 -0
  71. package/src/runtime/prompt/builder.ts +167 -0
  72. package/src/runtime/provider/anthropic.ts +50 -0
  73. package/src/runtime/provider/copilot.ts +12 -0
  74. package/src/runtime/provider/google.ts +8 -0
  75. package/src/runtime/provider/index.ts +60 -0
  76. package/src/runtime/provider/moonshot.ts +8 -0
  77. package/src/runtime/provider/oauth-adapter.ts +237 -0
  78. package/src/runtime/provider/openai.ts +18 -0
  79. package/src/runtime/provider/opencode.ts +7 -0
  80. package/src/runtime/provider/openrouter.ts +7 -0
  81. package/src/runtime/provider/selection.ts +118 -0
  82. package/src/runtime/provider/setu.ts +126 -0
  83. package/src/runtime/provider/zai.ts +16 -0
  84. package/src/runtime/session/branch.ts +280 -0
  85. package/src/runtime/session/db-operations.ts +285 -0
  86. package/src/runtime/session/manager.ts +99 -0
  87. package/src/runtime/session/queue.ts +243 -0
  88. package/src/runtime/stream/abort-handler.ts +65 -0
  89. package/src/runtime/stream/error-handler.ts +371 -0
  90. package/src/runtime/stream/finish-handler.ts +101 -0
  91. package/src/runtime/stream/handlers.ts +5 -0
  92. package/src/runtime/stream/step-finish.ts +93 -0
  93. package/src/runtime/stream/types.ts +25 -0
  94. package/src/runtime/tools/approval.ts +180 -0
  95. package/src/runtime/tools/context.ts +83 -0
  96. package/src/runtime/tools/mapping.ts +154 -0
  97. package/src/runtime/tools/setup.ts +44 -0
  98. package/src/runtime/topup/manager.ts +110 -0
  99. package/src/runtime/utils/cwd.ts +69 -0
  100. package/src/runtime/utils/token.ts +35 -0
  101. package/src/tools/adapter.ts +634 -0
  102. package/src/tools/database/get-parent-session.ts +183 -0
  103. package/src/tools/database/get-session-context.ts +161 -0
  104. package/src/tools/database/index.ts +42 -0
  105. package/src/tools/database/present-session-links.ts +47 -0
  106. package/src/tools/database/query-messages.ts +160 -0
  107. package/src/tools/database/query-sessions.ts +126 -0
  108. package/src/tools/database/search-history.ts +135 -0
  109. package/src/types/sql-imports.d.ts +5 -0
  110. package/sst-env.d.ts +8 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,814 @@
1
+ import type { Hono } from 'hono';
2
+ import { loadConfig } from '@ottocode/sdk';
3
+ import { userInfo } from 'node:os';
4
+ import { getDb } from '@ottocode/database';
5
+ import {
6
+ sessions,
7
+ messages,
8
+ messageParts,
9
+ shares,
10
+ } from '@ottocode/database/schema';
11
+ import { desc, eq, and, ne, inArray, or } from 'drizzle-orm';
12
+ import type { ProviderId } from '@ottocode/sdk';
13
+ import { isProviderId, catalog } from '@ottocode/sdk';
14
+ import { resolveAgentConfig } from '../runtime/agent/registry.ts';
15
+ import { createSession as createSessionRow } from '../runtime/session/manager.ts';
16
+ import { serializeError } from '../runtime/errors/api-error.ts';
17
+ import { logger } from '@ottocode/sdk';
18
+
19
+ export function registerSessionsRoutes(app: Hono) {
20
+ // List sessions
21
+ app.get('/v1/sessions', async (c) => {
22
+ const projectRoot = c.req.query('project') || process.cwd();
23
+ const cfg = await loadConfig(projectRoot);
24
+ const db = await getDb(cfg.projectRoot);
25
+ // Only return sessions for this project, excluding research sessions
26
+ const rows = await db
27
+ .select()
28
+ .from(sessions)
29
+ .where(
30
+ and(
31
+ eq(sessions.projectPath, cfg.projectRoot),
32
+ ne(sessions.sessionType, 'research'),
33
+ ),
34
+ )
35
+ .orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt));
36
+ const normalized = rows.map((r) => {
37
+ let counts: Record<string, unknown> | undefined;
38
+ if (r.toolCountsJson) {
39
+ try {
40
+ const parsed = JSON.parse(r.toolCountsJson);
41
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
42
+ counts = parsed as Record<string, unknown>;
43
+ }
44
+ } catch {}
45
+ }
46
+ const { toolCountsJson: _toolCountsJson, ...rest } = r;
47
+ return counts ? { ...rest, toolCounts: counts } : rest;
48
+ });
49
+ return c.json(normalized);
50
+ });
51
+
52
+ // Create session
53
+ app.post('/v1/sessions', async (c) => {
54
+ const projectRoot = c.req.query('project') || process.cwd();
55
+ const cfg = await loadConfig(projectRoot);
56
+ const db = await getDb(cfg.projectRoot);
57
+ const body = (await c.req.json().catch(() => ({}))) as Record<
58
+ string,
59
+ unknown
60
+ >;
61
+ const agent = (body.agent as string | undefined) ?? cfg.defaults.agent;
62
+ const agentCfg = await resolveAgentConfig(cfg.projectRoot, agent);
63
+ const providerCandidate =
64
+ typeof body.provider === 'string' ? body.provider : undefined;
65
+ const provider: ProviderId = (() => {
66
+ if (providerCandidate && isProviderId(providerCandidate))
67
+ return providerCandidate;
68
+ if (agentCfg.provider && isProviderId(agentCfg.provider))
69
+ return agentCfg.provider;
70
+ return cfg.defaults.provider;
71
+ })();
72
+ const modelCandidate =
73
+ typeof body.model === 'string' ? body.model.trim() : undefined;
74
+ const model = modelCandidate?.length
75
+ ? modelCandidate
76
+ : (agentCfg.model ?? cfg.defaults.model);
77
+ try {
78
+ const row = await createSessionRow({
79
+ db,
80
+ cfg,
81
+ agent,
82
+ provider,
83
+ model,
84
+ title: (body.title as string | null | undefined) ?? null,
85
+ });
86
+ return c.json(row, 201);
87
+ } catch (err) {
88
+ logger.error('Failed to create session', err);
89
+ const errorResponse = serializeError(err);
90
+ return c.json(errorResponse, errorResponse.error.status || 400);
91
+ }
92
+ });
93
+
94
+ // Update session preferences
95
+ app.patch('/v1/sessions/:sessionId', async (c) => {
96
+ try {
97
+ const sessionId = c.req.param('sessionId');
98
+ const projectRoot = c.req.query('project') || process.cwd();
99
+ const cfg = await loadConfig(projectRoot);
100
+ const db = await getDb(cfg.projectRoot);
101
+
102
+ const body = (await c.req.json().catch(() => ({}))) as Record<
103
+ string,
104
+ unknown
105
+ >;
106
+
107
+ // Fetch existing session
108
+ const existingRows = await db
109
+ .select()
110
+ .from(sessions)
111
+ .where(eq(sessions.id, sessionId))
112
+ .limit(1);
113
+
114
+ if (!existingRows.length) {
115
+ return c.json({ error: 'Session not found' }, 404);
116
+ }
117
+
118
+ const existingSession = existingRows[0];
119
+
120
+ // Verify session belongs to current project
121
+ if (existingSession.projectPath !== cfg.projectRoot) {
122
+ return c.json({ error: 'Session not found in this project' }, 404);
123
+ }
124
+
125
+ // Prepare update data
126
+ const updates: {
127
+ agent?: string;
128
+ provider?: string;
129
+ model?: string;
130
+ lastActiveAt?: number;
131
+ } = {
132
+ lastActiveAt: Date.now(),
133
+ };
134
+
135
+ // Validate agent if provided
136
+ if (typeof body.agent === 'string') {
137
+ const agentName = body.agent.trim();
138
+ if (agentName) {
139
+ // Agent validation: check if it exists via resolveAgentConfig
140
+ try {
141
+ await resolveAgentConfig(cfg.projectRoot, agentName);
142
+ updates.agent = agentName;
143
+ } catch (err) {
144
+ logger.warn('Invalid agent provided', { agent: agentName, err });
145
+ return c.json({ error: `Invalid agent: ${agentName}` }, 400);
146
+ }
147
+ }
148
+ }
149
+
150
+ // Validate provider if provided
151
+ if (typeof body.provider === 'string') {
152
+ const providerName = body.provider.trim();
153
+ if (providerName && isProviderId(providerName)) {
154
+ updates.provider = providerName;
155
+ } else if (providerName) {
156
+ return c.json({ error: `Invalid provider: ${providerName}` }, 400);
157
+ }
158
+ }
159
+
160
+ // Validate model if provided (and optionally verify it belongs to provider)
161
+ if (typeof body.model === 'string') {
162
+ const modelName = body.model.trim();
163
+ if (modelName) {
164
+ const targetProvider = (updates.provider ||
165
+ existingSession.provider) as ProviderId;
166
+
167
+ // Check if model exists for the provider
168
+ const providerCatalog = catalog[targetProvider];
169
+ if (providerCatalog) {
170
+ const modelExists = providerCatalog.models.some(
171
+ (m) => m.id === modelName,
172
+ );
173
+ if (!modelExists) {
174
+ return c.json(
175
+ {
176
+ error: `Model "${modelName}" not found for provider "${targetProvider}"`,
177
+ },
178
+ 400,
179
+ );
180
+ }
181
+ }
182
+
183
+ updates.model = modelName;
184
+ }
185
+ }
186
+
187
+ // Perform update
188
+ await db.update(sessions).set(updates).where(eq(sessions.id, sessionId));
189
+
190
+ // Return updated session
191
+ const updatedRows = await db
192
+ .select()
193
+ .from(sessions)
194
+ .where(eq(sessions.id, sessionId))
195
+ .limit(1);
196
+
197
+ return c.json(updatedRows[0]);
198
+ } catch (err) {
199
+ logger.error('Failed to update session', err);
200
+ const errorResponse = serializeError(err);
201
+ return c.json(errorResponse, errorResponse.error.status || 500);
202
+ }
203
+ });
204
+
205
+ // Delete session
206
+ app.delete('/v1/sessions/:sessionId', async (c) => {
207
+ try {
208
+ const sessionId = c.req.param('sessionId');
209
+ const projectRoot = c.req.query('project') || process.cwd();
210
+ const cfg = await loadConfig(projectRoot);
211
+ const db = await getDb(cfg.projectRoot);
212
+
213
+ const existingRows = await db
214
+ .select()
215
+ .from(sessions)
216
+ .where(eq(sessions.id, sessionId))
217
+ .limit(1);
218
+
219
+ if (!existingRows.length) {
220
+ return c.json({ error: 'Session not found' }, 404);
221
+ }
222
+
223
+ const existingSession = existingRows[0];
224
+
225
+ if (existingSession.projectPath !== cfg.projectRoot) {
226
+ return c.json({ error: 'Session not found in this project' }, 404);
227
+ }
228
+
229
+ await db
230
+ .delete(messageParts)
231
+ .where(
232
+ inArray(
233
+ messageParts.messageId,
234
+ db
235
+ .select({ id: messages.id })
236
+ .from(messages)
237
+ .where(eq(messages.sessionId, sessionId)),
238
+ ),
239
+ );
240
+ await db.delete(messages).where(eq(messages.sessionId, sessionId));
241
+ await db.delete(sessions).where(eq(sessions.id, sessionId));
242
+
243
+ return c.json({ success: true });
244
+ } catch (err) {
245
+ logger.error('Failed to delete session', err);
246
+ const errorResponse = serializeError(err);
247
+ return c.json(errorResponse, errorResponse.error.status || 500);
248
+ }
249
+ });
250
+
251
+ // Abort session stream
252
+ app.delete('/v1/sessions/:sessionId/abort', async (c) => {
253
+ const sessionId = c.req.param('sessionId');
254
+ const body = (await c.req.json().catch(() => ({}))) as Record<
255
+ string,
256
+ unknown
257
+ >;
258
+ const messageId =
259
+ typeof body.messageId === 'string' ? body.messageId : undefined;
260
+ const clearQueue = body.clearQueue === true;
261
+
262
+ const { abortSession, abortMessage } = await import(
263
+ '../runtime/agent/runner.ts'
264
+ );
265
+
266
+ if (messageId) {
267
+ const result = abortMessage(sessionId, messageId);
268
+ return c.json({
269
+ success: result.removed,
270
+ wasRunning: result.wasRunning,
271
+ messageId,
272
+ });
273
+ }
274
+
275
+ abortSession(sessionId, clearQueue);
276
+ return c.json({ success: true });
277
+ });
278
+
279
+ // Get queue state for a session
280
+ app.get('/v1/sessions/:sessionId/queue', async (c) => {
281
+ const sessionId = c.req.param('sessionId');
282
+ const { getQueueState } = await import('../runtime/session/queue.ts');
283
+ const state = getQueueState(sessionId);
284
+ return c.json(
285
+ state ?? {
286
+ currentMessageId: null,
287
+ queuedMessages: [],
288
+ isRunning: false,
289
+ },
290
+ );
291
+ });
292
+
293
+ // Remove a message from the queue
294
+ app.delete('/v1/sessions/:sessionId/queue/:messageId', async (c) => {
295
+ const sessionId = c.req.param('sessionId');
296
+ const messageId = c.req.param('messageId');
297
+ const projectRoot = c.req.query('project') || process.cwd();
298
+ const cfg = await loadConfig(projectRoot);
299
+ const db = await getDb(cfg.projectRoot);
300
+ const { removeFromQueue, abortMessage } = await import(
301
+ '../runtime/session/queue.ts'
302
+ );
303
+
304
+ // First try to remove from queue (queued messages)
305
+ const removed = removeFromQueue(sessionId, messageId);
306
+ if (removed) {
307
+ // Delete messages from database
308
+ try {
309
+ // Find the assistant message to get its creation time
310
+ const assistantMsg = await db
311
+ .select()
312
+ .from(messages)
313
+ .where(eq(messages.id, messageId))
314
+ .limit(1);
315
+
316
+ if (assistantMsg.length > 0) {
317
+ // Find the user message that came right before (same session, created just before)
318
+ const userMsg = await db
319
+ .select()
320
+ .from(messages)
321
+ .where(
322
+ and(eq(messages.sessionId, sessionId), eq(messages.role, 'user')),
323
+ )
324
+ .orderBy(desc(messages.createdAt))
325
+ .limit(1);
326
+
327
+ const messageIdsToDelete = [messageId];
328
+ if (userMsg.length > 0) {
329
+ messageIdsToDelete.push(userMsg[0].id);
330
+ }
331
+
332
+ // Delete message parts first (foreign key constraint)
333
+ await db
334
+ .delete(messageParts)
335
+ .where(inArray(messageParts.messageId, messageIdsToDelete));
336
+ // Delete messages
337
+ await db
338
+ .delete(messages)
339
+ .where(inArray(messages.id, messageIdsToDelete));
340
+ }
341
+ } catch (err) {
342
+ logger.error('Failed to delete queued messages from DB', err);
343
+ }
344
+ return c.json({ success: true, removed: true, wasQueued: true });
345
+ }
346
+
347
+ // If not in queue, try to abort (might be running)
348
+ const result = abortMessage(sessionId, messageId);
349
+ if (result.removed) {
350
+ return c.json({
351
+ success: true,
352
+ removed: true,
353
+ wasQueued: false,
354
+ wasRunning: result.wasRunning,
355
+ });
356
+ }
357
+
358
+ // If not queued or running, try to delete directly from database
359
+ // This handles system messages (like injected research context)
360
+ try {
361
+ const existingMsg = await db
362
+ .select()
363
+ .from(messages)
364
+ .where(
365
+ and(eq(messages.id, messageId), eq(messages.sessionId, sessionId)),
366
+ )
367
+ .limit(1);
368
+
369
+ if (existingMsg.length > 0) {
370
+ // Delete message parts first (foreign key constraint)
371
+ await db
372
+ .delete(messageParts)
373
+ .where(eq(messageParts.messageId, messageId));
374
+ // Delete message
375
+ await db.delete(messages).where(eq(messages.id, messageId));
376
+
377
+ return c.json({ success: true, removed: true, wasStored: true });
378
+ }
379
+ } catch (err) {
380
+ logger.error('Failed to delete message from DB', err);
381
+ return c.json({ success: false, error: 'Failed to delete message' }, 500);
382
+ }
383
+
384
+ return c.json({ success: false, removed: false }, 404);
385
+ });
386
+
387
+ app.get('/v1/sessions/:sessionId/share', async (c) => {
388
+ const sessionId = c.req.param('sessionId');
389
+ const projectRoot = c.req.query('project') || process.cwd();
390
+ const cfg = await loadConfig(projectRoot);
391
+ const db = await getDb(cfg.projectRoot);
392
+
393
+ const share = await db
394
+ .select()
395
+ .from(shares)
396
+ .where(eq(shares.sessionId, sessionId))
397
+ .limit(1);
398
+
399
+ if (!share.length) {
400
+ return c.json({ shared: false });
401
+ }
402
+
403
+ const allMessages = await db
404
+ .select({ id: messages.id })
405
+ .from(messages)
406
+ .where(eq(messages.sessionId, sessionId))
407
+ .orderBy(messages.createdAt);
408
+
409
+ const totalMessages = allMessages.length;
410
+ const syncedIdx = allMessages.findIndex(
411
+ (m) => m.id === share[0].lastSyncedMessageId,
412
+ );
413
+ const syncedMessages = syncedIdx === -1 ? 0 : syncedIdx + 1;
414
+ const pendingMessages = totalMessages - syncedMessages;
415
+
416
+ return c.json({
417
+ shared: true,
418
+ shareId: share[0].shareId,
419
+ url: share[0].url,
420
+ title: share[0].title,
421
+ createdAt: share[0].createdAt,
422
+ lastSyncedAt: share[0].lastSyncedAt,
423
+ lastSyncedMessageId: share[0].lastSyncedMessageId,
424
+ syncedMessages,
425
+ totalMessages,
426
+ pendingMessages,
427
+ isSynced: pendingMessages === 0,
428
+ });
429
+ });
430
+
431
+ const SHARE_API_URL =
432
+ process.env.OTTO_SHARE_API_URL || 'https://api.share.ottocode.io';
433
+
434
+ function getUsername(): string {
435
+ try {
436
+ return userInfo().username;
437
+ } catch {
438
+ return 'anonymous';
439
+ }
440
+ }
441
+
442
+ app.post('/v1/sessions/:sessionId/share', async (c) => {
443
+ const sessionId = c.req.param('sessionId');
444
+ const projectRoot = c.req.query('project') || process.cwd();
445
+ const cfg = await loadConfig(projectRoot);
446
+ const db = await getDb(cfg.projectRoot);
447
+
448
+ const session = await db
449
+ .select()
450
+ .from(sessions)
451
+ .where(eq(sessions.id, sessionId))
452
+ .limit(1);
453
+ if (!session.length) {
454
+ return c.json({ error: 'Session not found' }, 404);
455
+ }
456
+
457
+ const existingShare = await db
458
+ .select()
459
+ .from(shares)
460
+ .where(eq(shares.sessionId, sessionId))
461
+ .limit(1);
462
+ if (existingShare.length) {
463
+ return c.json({
464
+ shared: true,
465
+ shareId: existingShare[0].shareId,
466
+ url: existingShare[0].url,
467
+ message: 'Already shared',
468
+ });
469
+ }
470
+
471
+ const allMessages = await db
472
+ .select()
473
+ .from(messages)
474
+ .where(eq(messages.sessionId, sessionId))
475
+ .orderBy(messages.createdAt);
476
+
477
+ if (!allMessages.length) {
478
+ return c.json({ error: 'Session has no messages' }, 400);
479
+ }
480
+
481
+ const msgParts = await db
482
+ .select()
483
+ .from(messageParts)
484
+ .where(
485
+ inArray(
486
+ messageParts.messageId,
487
+ allMessages.map((m) => m.id),
488
+ ),
489
+ )
490
+ .orderBy(messageParts.index);
491
+
492
+ const partsByMessage = new Map<string, typeof msgParts>();
493
+ for (const part of msgParts) {
494
+ const list = partsByMessage.get(part.messageId) || [];
495
+ list.push(part);
496
+ partsByMessage.set(part.messageId, list);
497
+ }
498
+
499
+ const lastMessageId = allMessages[allMessages.length - 1].id;
500
+ const sess = session[0];
501
+
502
+ const sessionData = {
503
+ title: sess.title,
504
+ username: getUsername(),
505
+ agent: sess.agent,
506
+ provider: sess.provider,
507
+ model: sess.model,
508
+ createdAt: sess.createdAt,
509
+ stats: {
510
+ inputTokens: sess.totalInputTokens ?? 0,
511
+ outputTokens: sess.totalOutputTokens ?? 0,
512
+ cachedTokens: sess.totalCachedTokens ?? 0,
513
+ cacheCreationTokens: sess.totalCacheCreationTokens ?? 0,
514
+ reasoningTokens: sess.totalReasoningTokens ?? 0,
515
+ toolTimeMs: sess.totalToolTimeMs ?? 0,
516
+ toolCounts: sess.toolCountsJson ? JSON.parse(sess.toolCountsJson) : {},
517
+ },
518
+ messages: allMessages.map((m) => ({
519
+ id: m.id,
520
+ role: m.role,
521
+ createdAt: m.createdAt,
522
+ parts: (partsByMessage.get(m.id) || []).map((p) => ({
523
+ type: p.type,
524
+ content: p.content,
525
+ toolName: p.toolName,
526
+ toolCallId: p.toolCallId,
527
+ })),
528
+ })),
529
+ };
530
+
531
+ const res = await fetch(`${SHARE_API_URL}/share`, {
532
+ method: 'POST',
533
+ headers: { 'Content-Type': 'application/json' },
534
+ body: JSON.stringify({
535
+ sessionData,
536
+ title: sess.title,
537
+ lastMessageId,
538
+ }),
539
+ });
540
+
541
+ if (!res.ok) {
542
+ const err = await res.text();
543
+ return c.json({ error: `Failed to create share: ${err}` }, 500);
544
+ }
545
+
546
+ const data = (await res.json()) as {
547
+ shareId: string;
548
+ secret: string;
549
+ url: string;
550
+ };
551
+
552
+ await db.insert(shares).values({
553
+ sessionId,
554
+ shareId: data.shareId,
555
+ secret: data.secret,
556
+ url: data.url,
557
+ title: sess.title,
558
+ description: null,
559
+ createdAt: Date.now(),
560
+ lastSyncedAt: Date.now(),
561
+ lastSyncedMessageId: lastMessageId,
562
+ });
563
+
564
+ return c.json({
565
+ shared: true,
566
+ shareId: data.shareId,
567
+ url: data.url,
568
+ });
569
+ });
570
+
571
+ app.put('/v1/sessions/:sessionId/share', async (c) => {
572
+ const sessionId = c.req.param('sessionId');
573
+ const projectRoot = c.req.query('project') || process.cwd();
574
+ const cfg = await loadConfig(projectRoot);
575
+ const db = await getDb(cfg.projectRoot);
576
+
577
+ const share = await db
578
+ .select()
579
+ .from(shares)
580
+ .where(eq(shares.sessionId, sessionId))
581
+ .limit(1);
582
+ if (!share.length) {
583
+ return c.json({ error: 'Session not shared. Use share first.' }, 400);
584
+ }
585
+
586
+ const session = await db
587
+ .select()
588
+ .from(sessions)
589
+ .where(eq(sessions.id, sessionId))
590
+ .limit(1);
591
+ if (!session.length) {
592
+ return c.json({ error: 'Session not found' }, 404);
593
+ }
594
+
595
+ const allMessages = await db
596
+ .select()
597
+ .from(messages)
598
+ .where(eq(messages.sessionId, sessionId))
599
+ .orderBy(messages.createdAt);
600
+
601
+ const msgParts = await db
602
+ .select()
603
+ .from(messageParts)
604
+ .where(
605
+ inArray(
606
+ messageParts.messageId,
607
+ allMessages.map((m) => m.id),
608
+ ),
609
+ )
610
+ .orderBy(messageParts.index);
611
+
612
+ const partsByMessage = new Map<string, typeof msgParts>();
613
+ for (const part of msgParts) {
614
+ const list = partsByMessage.get(part.messageId) || [];
615
+ list.push(part);
616
+ partsByMessage.set(part.messageId, list);
617
+ }
618
+
619
+ const lastSyncedIdx = allMessages.findIndex(
620
+ (m) => m.id === share[0].lastSyncedMessageId,
621
+ );
622
+ const newMessages =
623
+ lastSyncedIdx === -1 ? allMessages : allMessages.slice(lastSyncedIdx + 1);
624
+ const lastMessageId =
625
+ allMessages[allMessages.length - 1]?.id ?? share[0].lastSyncedMessageId;
626
+
627
+ if (newMessages.length === 0) {
628
+ return c.json({
629
+ synced: true,
630
+ url: share[0].url,
631
+ newMessages: 0,
632
+ message: 'Already synced',
633
+ });
634
+ }
635
+
636
+ const sess = session[0];
637
+ const sessionData = {
638
+ title: sess.title,
639
+ username: getUsername(),
640
+ agent: sess.agent,
641
+ provider: sess.provider,
642
+ model: sess.model,
643
+ createdAt: sess.createdAt,
644
+ stats: {
645
+ inputTokens: sess.totalInputTokens ?? 0,
646
+ outputTokens: sess.totalOutputTokens ?? 0,
647
+ cachedTokens: sess.totalCachedTokens ?? 0,
648
+ cacheCreationTokens: sess.totalCacheCreationTokens ?? 0,
649
+ reasoningTokens: sess.totalReasoningTokens ?? 0,
650
+ toolTimeMs: sess.totalToolTimeMs ?? 0,
651
+ toolCounts: sess.toolCountsJson ? JSON.parse(sess.toolCountsJson) : {},
652
+ },
653
+ messages: allMessages.map((m) => ({
654
+ id: m.id,
655
+ role: m.role,
656
+ createdAt: m.createdAt,
657
+ parts: (partsByMessage.get(m.id) || []).map((p) => ({
658
+ type: p.type,
659
+ content: p.content,
660
+ toolName: p.toolName,
661
+ toolCallId: p.toolCallId,
662
+ })),
663
+ })),
664
+ };
665
+
666
+ const res = await fetch(`${SHARE_API_URL}/share/${share[0].shareId}`, {
667
+ method: 'PUT',
668
+ headers: {
669
+ 'Content-Type': 'application/json',
670
+ 'X-Share-Secret': share[0].secret,
671
+ },
672
+ body: JSON.stringify({
673
+ sessionData,
674
+ title: sess.title,
675
+ lastMessageId,
676
+ }),
677
+ });
678
+
679
+ if (!res.ok) {
680
+ const err = await res.text();
681
+ return c.json({ error: `Failed to sync share: ${err}` }, 500);
682
+ }
683
+
684
+ await db
685
+ .update(shares)
686
+ .set({
687
+ title: sess.title,
688
+ lastSyncedAt: Date.now(),
689
+ lastSyncedMessageId: lastMessageId,
690
+ })
691
+ .where(eq(shares.sessionId, sessionId));
692
+
693
+ return c.json({
694
+ synced: true,
695
+ url: share[0].url,
696
+ newMessages: newMessages.length,
697
+ });
698
+ });
699
+
700
+ // Retry a failed assistant message
701
+ app.post('/v1/sessions/:sessionId/messages/:messageId/retry', async (c) => {
702
+ try {
703
+ const sessionId = c.req.param('sessionId');
704
+ const messageId = c.req.param('messageId');
705
+ const projectRoot = c.req.query('project') || process.cwd();
706
+ const cfg = await loadConfig(projectRoot);
707
+ const db = await getDb(cfg.projectRoot);
708
+
709
+ // Get the assistant message
710
+ const [assistantMsg] = await db
711
+ .select()
712
+ .from(messages)
713
+ .where(
714
+ and(
715
+ eq(messages.id, messageId),
716
+ eq(messages.sessionId, sessionId),
717
+ eq(messages.role, 'assistant'),
718
+ ),
719
+ )
720
+ .limit(1);
721
+
722
+ if (!assistantMsg) {
723
+ return c.json({ error: 'Message not found' }, 404);
724
+ }
725
+
726
+ // Only allow retry on error or complete messages
727
+ if (
728
+ assistantMsg.status !== 'error' &&
729
+ assistantMsg.status !== 'complete'
730
+ ) {
731
+ return c.json(
732
+ { error: 'Can only retry error or complete messages' },
733
+ 400,
734
+ );
735
+ }
736
+
737
+ // Get session for context
738
+ const [session] = await db
739
+ .select()
740
+ .from(sessions)
741
+ .where(eq(sessions.id, sessionId))
742
+ .limit(1);
743
+
744
+ if (!session) {
745
+ return c.json({ error: 'Session not found' }, 404);
746
+ }
747
+
748
+ // Delete only error parts - preserve valid text/tool content
749
+ await db
750
+ .delete(messageParts)
751
+ .where(
752
+ and(
753
+ eq(messageParts.messageId, messageId),
754
+ or(
755
+ eq(messageParts.type, 'error'),
756
+ and(
757
+ eq(messageParts.type, 'tool_call'),
758
+ eq(messageParts.toolName, 'finish'),
759
+ ),
760
+ ),
761
+ ),
762
+ );
763
+
764
+ // Reset message status to pending
765
+ await db
766
+ .update(messages)
767
+ .set({
768
+ status: 'pending',
769
+ error: null,
770
+ errorType: null,
771
+ errorDetails: null,
772
+ completedAt: null,
773
+ })
774
+ .where(eq(messages.id, messageId));
775
+
776
+ // Emit event so UI updates
777
+ const { publish } = await import('../events/bus.ts');
778
+ publish({
779
+ type: 'message.updated',
780
+ sessionId,
781
+ payload: { id: messageId, status: 'pending' },
782
+ });
783
+
784
+ // Re-enqueue the assistant run
785
+ const { enqueueAssistantRun } = await import(
786
+ '../runtime/session/queue.ts'
787
+ );
788
+ const { runSessionLoop } = await import('../runtime/agent/runner.ts');
789
+
790
+ const toolApprovalMode = cfg.defaults.toolApproval ?? 'auto';
791
+
792
+ enqueueAssistantRun(
793
+ {
794
+ sessionId,
795
+ assistantMessageId: messageId,
796
+ agent: assistantMsg.agent ?? 'build',
797
+ provider: (assistantMsg.provider ??
798
+ cfg.defaults.provider) as ProviderId,
799
+ model: assistantMsg.model ?? cfg.defaults.model,
800
+ projectRoot: cfg.projectRoot,
801
+ oneShot: false,
802
+ toolApprovalMode,
803
+ },
804
+ runSessionLoop,
805
+ );
806
+
807
+ return c.json({ success: true, messageId });
808
+ } catch (err) {
809
+ logger.error('Failed to retry message', err);
810
+ const errorResponse = serializeError(err);
811
+ return c.json(errorResponse, errorResponse.error.status || 500);
812
+ }
813
+ });
814
+ }