@ottocode/server 0.1.265 → 0.1.266

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 (72) hide show
  1. package/package.json +3 -3
  2. package/src/routes/auth/copilot.ts +699 -0
  3. package/src/routes/auth/oauth.ts +578 -0
  4. package/src/routes/auth/onboarding.ts +45 -0
  5. package/src/routes/auth/providers.ts +189 -0
  6. package/src/routes/auth/service.ts +167 -0
  7. package/src/routes/auth/state.ts +23 -0
  8. package/src/routes/auth/status.ts +203 -0
  9. package/src/routes/auth/wallet.ts +229 -0
  10. package/src/routes/auth.ts +12 -2080
  11. package/src/routes/config/models-service.ts +411 -0
  12. package/src/routes/config/models.ts +6 -426
  13. package/src/routes/config/providers-service.ts +237 -0
  14. package/src/routes/config/providers.ts +10 -242
  15. package/src/routes/files/handlers.ts +297 -0
  16. package/src/routes/files/service.ts +313 -0
  17. package/src/routes/files.ts +12 -608
  18. package/src/routes/git/commit-service.ts +207 -0
  19. package/src/routes/git/commit.ts +6 -220
  20. package/src/routes/git/remote-service.ts +116 -0
  21. package/src/routes/git/remote.ts +8 -115
  22. package/src/routes/git/staging-service.ts +111 -0
  23. package/src/routes/git/staging.ts +10 -205
  24. package/src/routes/mcp/auth.ts +338 -0
  25. package/src/routes/mcp/lifecycle.ts +263 -0
  26. package/src/routes/mcp/servers.ts +212 -0
  27. package/src/routes/mcp/service.ts +664 -0
  28. package/src/routes/mcp/state.ts +13 -0
  29. package/src/routes/mcp.ts +6 -1233
  30. package/src/routes/ottorouter/billing.ts +593 -0
  31. package/src/routes/ottorouter/service.ts +92 -0
  32. package/src/routes/ottorouter/topup.ts +301 -0
  33. package/src/routes/ottorouter/wallet.ts +370 -0
  34. package/src/routes/ottorouter.ts +6 -1319
  35. package/src/routes/research/service.ts +339 -0
  36. package/src/routes/research.ts +12 -390
  37. package/src/routes/sessions/crud.ts +563 -0
  38. package/src/routes/sessions/queue.ts +242 -0
  39. package/src/routes/sessions/retry.ts +121 -0
  40. package/src/routes/sessions/service.ts +768 -0
  41. package/src/routes/sessions/share.ts +434 -0
  42. package/src/routes/sessions.ts +8 -1977
  43. package/src/routes/skills/service.ts +221 -0
  44. package/src/routes/skills/spec.ts +309 -0
  45. package/src/routes/skills.ts +31 -909
  46. package/src/routes/terminals/service.ts +326 -0
  47. package/src/routes/terminals.ts +19 -295
  48. package/src/routes/tunnel/service.ts +217 -0
  49. package/src/routes/tunnel.ts +29 -219
  50. package/src/runtime/agent/registry-prompts.ts +147 -0
  51. package/src/runtime/agent/registry.ts +6 -124
  52. package/src/runtime/agent/runner-errors.ts +116 -0
  53. package/src/runtime/agent/runner-reminders.ts +45 -0
  54. package/src/runtime/agent/runner-setup-model.ts +75 -0
  55. package/src/runtime/agent/runner-setup-prompt.ts +185 -0
  56. package/src/runtime/agent/runner-setup-tools.ts +103 -0
  57. package/src/runtime/agent/runner-setup-utils.ts +21 -0
  58. package/src/runtime/agent/runner-setup.ts +54 -288
  59. package/src/runtime/agent/runner-telemetry.ts +112 -0
  60. package/src/runtime/agent/runner-text.ts +108 -0
  61. package/src/runtime/agent/runner-tool-observer.ts +86 -0
  62. package/src/runtime/agent/runner.ts +79 -378
  63. package/src/runtime/provider/custom.ts +73 -0
  64. package/src/runtime/provider/index.ts +2 -85
  65. package/src/runtime/provider/reasoning-builders.ts +280 -0
  66. package/src/runtime/provider/reasoning.ts +67 -264
  67. package/src/tools/adapter/events.ts +116 -0
  68. package/src/tools/adapter/execution.ts +160 -0
  69. package/src/tools/adapter/pending.ts +37 -0
  70. package/src/tools/adapter/persistence.ts +166 -0
  71. package/src/tools/adapter/results.ts +97 -0
  72. package/src/tools/adapter.ts +124 -451
@@ -1,1981 +1,12 @@
1
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 { hasConfiguredProvider, validateProviderModel } 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
- import { getRunnerState } from '../runtime/session/queue.ts';
19
- import { openApiRoute } from '../openapi/route.ts';
2
+ import { registerSessionCrudRoutes } from './sessions/crud.ts';
3
+ import { registerSessionQueueRoutes } from './sessions/queue.ts';
4
+ import { registerSessionRetryRoutes } from './sessions/retry.ts';
5
+ import { registerSessionShareRoutes } from './sessions/share.ts';
20
6
 
21
7
  export function registerSessionsRoutes(app: Hono) {
22
- // List sessions
23
- openApiRoute(
24
- app,
25
- {
26
- method: 'get',
27
- path: '/v1/sessions',
28
- tags: ['sessions'],
29
- operationId: 'listSessions',
30
- summary: 'List sessions',
31
- parameters: [
32
- {
33
- in: 'query',
34
- name: 'project',
35
- required: false,
36
- schema: {
37
- type: 'string',
38
- },
39
- description:
40
- 'Project root override (defaults to current working directory).',
41
- },
42
- {
43
- in: 'query',
44
- name: 'limit',
45
- schema: {
46
- type: 'integer',
47
- default: 50,
48
- minimum: 1,
49
- maximum: 200,
50
- },
51
- description: 'Maximum number of sessions to return',
52
- },
53
- {
54
- in: 'query',
55
- name: 'offset',
56
- schema: {
57
- type: 'integer',
58
- default: 0,
59
- minimum: 0,
60
- },
61
- description: 'Offset for pagination',
62
- },
63
- ],
64
- responses: {
65
- '200': {
66
- description: 'OK',
67
- content: {
68
- 'application/json': {
69
- schema: {
70
- type: 'object',
71
- properties: {
72
- items: {
73
- type: 'array',
74
- items: {
75
- $ref: '#/components/schemas/Session',
76
- },
77
- },
78
- hasMore: {
79
- type: 'boolean',
80
- },
81
- nextOffset: {
82
- type: 'integer',
83
- nullable: true,
84
- },
85
- },
86
- required: ['items', 'hasMore', 'nextOffset'],
87
- },
88
- },
89
- },
90
- },
91
- },
92
- },
93
- async (c) => {
94
- const projectRoot = c.req.query('project') || process.cwd();
95
- const limit = Math.min(
96
- Math.max(parseInt(c.req.query('limit') || '50', 10) || 50, 1),
97
- 200,
98
- );
99
- const offset = Math.max(
100
- parseInt(c.req.query('offset') || '0', 10) || 0,
101
- 0,
102
- );
103
- const cfg = await loadConfig(projectRoot);
104
- const db = await getDb(cfg.projectRoot);
105
- // Only return sessions for this project, excluding research sessions
106
- const rows = await db
107
- .select()
108
- .from(sessions)
109
- .where(
110
- and(
111
- eq(sessions.projectPath, cfg.projectRoot),
112
- ne(sessions.sessionType, 'research'),
113
- ),
114
- )
115
- .orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt))
116
- .limit(limit + 1)
117
- .offset(offset);
118
- const hasMore = rows.length > limit;
119
- const page = hasMore ? rows.slice(0, limit) : rows;
120
- const normalized = page.map((r) => {
121
- let counts: Record<string, unknown> | undefined;
122
- if (r.toolCountsJson) {
123
- try {
124
- const parsed = JSON.parse(r.toolCountsJson);
125
- if (
126
- parsed &&
127
- typeof parsed === 'object' &&
128
- !Array.isArray(parsed)
129
- ) {
130
- counts = parsed as Record<string, unknown>;
131
- }
132
- } catch {}
133
- }
134
- const { toolCountsJson: _toolCountsJson, ...rest } = r;
135
- const isRunning = getRunnerState(r.id)?.running ?? false;
136
- const base = counts ? { ...rest, toolCounts: counts } : rest;
137
- return { ...base, isRunning };
138
- });
139
- return c.json({
140
- items: normalized,
141
- hasMore,
142
- nextOffset: hasMore ? offset + limit : null,
143
- });
144
- },
145
- );
146
-
147
- // Create session
148
- openApiRoute(
149
- app,
150
- {
151
- method: 'post',
152
- path: '/v1/sessions',
153
- tags: ['sessions'],
154
- operationId: 'createSession',
155
- summary: 'Create a new session',
156
- parameters: [
157
- {
158
- in: 'query',
159
- name: 'project',
160
- required: false,
161
- schema: {
162
- type: 'string',
163
- },
164
- description:
165
- 'Project root override (defaults to current working directory).',
166
- },
167
- ],
168
- requestBody: {
169
- required: false,
170
- content: {
171
- 'application/json': {
172
- schema: {
173
- type: 'object',
174
- properties: {
175
- title: {
176
- type: 'string',
177
- nullable: true,
178
- },
179
- agent: {
180
- type: 'string',
181
- },
182
- provider: {
183
- $ref: '#/components/schemas/Provider',
184
- },
185
- model: {
186
- type: 'string',
187
- },
188
- },
189
- },
190
- },
191
- },
192
- },
193
- responses: {
194
- '201': {
195
- description: 'Created',
196
- content: {
197
- 'application/json': {
198
- schema: {
199
- $ref: '#/components/schemas/Session',
200
- },
201
- },
202
- },
203
- },
204
- '400': {
205
- description: 'Bad Request',
206
- content: {
207
- 'application/json': {
208
- schema: {
209
- type: 'object',
210
- properties: {
211
- error: {
212
- type: 'string',
213
- },
214
- },
215
- required: ['error'],
216
- },
217
- },
218
- },
219
- },
220
- },
221
- },
222
- async (c) => {
223
- const projectRoot = c.req.query('project') || process.cwd();
224
- const cfg = await loadConfig(projectRoot);
225
- const db = await getDb(cfg.projectRoot);
226
- const body = (await c.req.json().catch(() => ({}))) as Record<
227
- string,
228
- unknown
229
- >;
230
- const agent = (body.agent as string | undefined) ?? cfg.defaults.agent;
231
- const agentCfg = await resolveAgentConfig(cfg.projectRoot, agent);
232
- const providerCandidate =
233
- typeof body.provider === 'string' ? body.provider : undefined;
234
- const provider: ProviderId = (() => {
235
- if (providerCandidate && hasConfiguredProvider(cfg, providerCandidate))
236
- return providerCandidate;
237
- if (hasConfiguredProvider(cfg, agentCfg.provider))
238
- return agentCfg.provider;
239
- return cfg.defaults.provider;
240
- })();
241
- const modelCandidate =
242
- typeof body.model === 'string' ? body.model.trim() : undefined;
243
- const model = modelCandidate?.length
244
- ? modelCandidate
245
- : (agentCfg.model ?? cfg.defaults.model);
246
- try {
247
- const row = await createSessionRow({
248
- db,
249
- cfg,
250
- agent,
251
- provider,
252
- model,
253
- title: (body.title as string | null | undefined) ?? null,
254
- });
255
- return c.json(row, 201);
256
- } catch (err) {
257
- logger.error('Failed to create session', err);
258
- const errorResponse = serializeError(err);
259
- return c.json(errorResponse, errorResponse.error.status || 400);
260
- }
261
- },
262
- );
263
-
264
- // Get single session
265
- openApiRoute(
266
- app,
267
- {
268
- method: 'get',
269
- path: '/v1/sessions/{sessionId}',
270
- tags: ['sessions'],
271
- operationId: 'getSession',
272
- summary: 'Get a single session by ID',
273
- parameters: [
274
- {
275
- in: 'path',
276
- name: 'sessionId',
277
- required: true,
278
- schema: {
279
- type: 'string',
280
- },
281
- },
282
- {
283
- in: 'query',
284
- name: 'project',
285
- required: false,
286
- schema: {
287
- type: 'string',
288
- },
289
- description:
290
- 'Project root override (defaults to current working directory).',
291
- },
292
- ],
293
- responses: {
294
- '200': {
295
- description: 'OK',
296
- content: {
297
- 'application/json': {
298
- schema: {
299
- $ref: '#/components/schemas/Session',
300
- },
301
- },
302
- },
303
- },
304
- '404': {
305
- description: 'Bad Request',
306
- content: {
307
- 'application/json': {
308
- schema: {
309
- type: 'object',
310
- properties: {
311
- error: {
312
- type: 'string',
313
- },
314
- },
315
- required: ['error'],
316
- },
317
- },
318
- },
319
- },
320
- },
321
- },
322
- async (c) => {
323
- try {
324
- const sessionId = c.req.param('sessionId');
325
- const projectRoot = c.req.query('project') || process.cwd();
326
- const cfg = await loadConfig(projectRoot);
327
- const db = await getDb(cfg.projectRoot);
328
- const rows = await db
329
- .select()
330
- .from(sessions)
331
- .where(eq(sessions.id, sessionId))
332
- .limit(1);
333
- if (!rows.length) {
334
- return c.json(
335
- { error: { message: 'Session not found', status: 404 } },
336
- 404,
337
- );
338
- }
339
- const r = rows[0];
340
- let counts: Record<string, unknown> | undefined;
341
- if (r.toolCountsJson) {
342
- try {
343
- const parsed = JSON.parse(r.toolCountsJson);
344
- if (
345
- parsed &&
346
- typeof parsed === 'object' &&
347
- !Array.isArray(parsed)
348
- ) {
349
- counts = parsed as Record<string, unknown>;
350
- }
351
- } catch {}
352
- }
353
- const { toolCountsJson: _toolCountsJson, ...rest } = r;
354
- return c.json(counts ? { ...rest, toolCounts: counts } : rest);
355
- } catch (err) {
356
- logger.error('Failed to get session', err);
357
- const errorResponse = serializeError(err);
358
- return c.json(errorResponse, errorResponse.error.status || 500);
359
- }
360
- },
361
- );
362
-
363
- // Update session preferences
364
- openApiRoute(
365
- app,
366
- {
367
- method: 'patch',
368
- path: '/v1/sessions/{sessionId}',
369
- tags: ['sessions'],
370
- operationId: 'updateSession',
371
- summary: 'Update session preferences',
372
- parameters: [
373
- {
374
- in: 'path',
375
- name: 'sessionId',
376
- required: true,
377
- schema: {
378
- type: 'string',
379
- },
380
- },
381
- {
382
- in: 'query',
383
- name: 'project',
384
- required: false,
385
- schema: {
386
- type: 'string',
387
- },
388
- description:
389
- 'Project root override (defaults to current working directory).',
390
- },
391
- ],
392
- requestBody: {
393
- required: true,
394
- content: {
395
- 'application/json': {
396
- schema: {
397
- type: 'object',
398
- properties: {
399
- title: {
400
- type: 'string',
401
- },
402
- agent: {
403
- type: 'string',
404
- },
405
- provider: {
406
- $ref: '#/components/schemas/Provider',
407
- },
408
- model: {
409
- type: 'string',
410
- },
411
- },
412
- },
413
- },
414
- },
415
- },
416
- responses: {
417
- '200': {
418
- description: 'OK',
419
- content: {
420
- 'application/json': {
421
- schema: {
422
- $ref: '#/components/schemas/Session',
423
- },
424
- },
425
- },
426
- },
427
- '400': {
428
- description: 'Bad Request',
429
- content: {
430
- 'application/json': {
431
- schema: {
432
- type: 'object',
433
- properties: {
434
- error: {
435
- type: 'string',
436
- },
437
- },
438
- required: ['error'],
439
- },
440
- },
441
- },
442
- },
443
- '404': {
444
- description: 'Bad Request',
445
- content: {
446
- 'application/json': {
447
- schema: {
448
- type: 'object',
449
- properties: {
450
- error: {
451
- type: 'string',
452
- },
453
- },
454
- required: ['error'],
455
- },
456
- },
457
- },
458
- },
459
- },
460
- },
461
- async (c) => {
462
- try {
463
- const sessionId = c.req.param('sessionId');
464
- const projectRoot = c.req.query('project') || process.cwd();
465
- const cfg = await loadConfig(projectRoot);
466
- const db = await getDb(cfg.projectRoot);
467
-
468
- const body = (await c.req.json().catch(() => ({}))) as Record<
469
- string,
470
- unknown
471
- >;
472
-
473
- // Fetch existing session
474
- const existingRows = await db
475
- .select()
476
- .from(sessions)
477
- .where(eq(sessions.id, sessionId))
478
- .limit(1);
479
-
480
- if (!existingRows.length) {
481
- return c.json({ error: 'Session not found' }, 404);
482
- }
483
-
484
- const existingSession = existingRows[0];
485
-
486
- // Verify session belongs to current project
487
- if (existingSession.projectPath !== cfg.projectRoot) {
488
- return c.json({ error: 'Session not found in this project' }, 404);
489
- }
490
-
491
- // Prepare update data
492
- const updates: {
493
- agent?: string;
494
- provider?: string;
495
- model?: string;
496
- title?: string | null;
497
- lastActiveAt?: number;
498
- } = {
499
- lastActiveAt: Date.now(),
500
- };
501
-
502
- if (typeof body.title === 'string') {
503
- updates.title = body.title.trim() || null;
504
- }
505
-
506
- // Validate agent if provided
507
- if (typeof body.agent === 'string') {
508
- const agentName = body.agent.trim();
509
- if (agentName) {
510
- // Agent validation: check if it exists via resolveAgentConfig
511
- try {
512
- await resolveAgentConfig(cfg.projectRoot, agentName);
513
- updates.agent = agentName;
514
- } catch (err) {
515
- logger.warn('Invalid agent provided', { agent: agentName, err });
516
- return c.json({ error: `Invalid agent: ${agentName}` }, 400);
517
- }
518
- }
519
- }
520
-
521
- // Validate provider if provided
522
- if (typeof body.provider === 'string') {
523
- const providerName = body.provider.trim();
524
- if (providerName && hasConfiguredProvider(cfg, providerName)) {
525
- updates.provider = providerName;
526
- } else if (providerName) {
527
- return c.json({ error: `Invalid provider: ${providerName}` }, 400);
528
- }
529
- }
530
-
531
- // Validate model if provided (and optionally verify it belongs to provider)
532
- if (typeof body.model === 'string') {
533
- const modelName = body.model.trim();
534
- if (modelName) {
535
- const targetProvider = (updates.provider ||
536
- existingSession.provider) as ProviderId;
537
- try {
538
- validateProviderModel(targetProvider, modelName, cfg);
539
- } catch {
540
- return c.json(
541
- {
542
- error: `Model "${modelName}" not found for provider "${targetProvider}"`,
543
- },
544
- 400,
545
- );
546
- }
547
-
548
- updates.model = modelName;
549
- }
550
- }
551
-
552
- // Perform update
553
- await db
554
- .update(sessions)
555
- .set(updates)
556
- .where(eq(sessions.id, sessionId));
557
-
558
- // Return updated session
559
- const updatedRows = await db
560
- .select()
561
- .from(sessions)
562
- .where(eq(sessions.id, sessionId))
563
- .limit(1);
564
-
565
- return c.json(updatedRows[0]);
566
- } catch (err) {
567
- logger.error('Failed to update session', err);
568
- const errorResponse = serializeError(err);
569
- return c.json(errorResponse, errorResponse.error.status || 500);
570
- }
571
- },
572
- );
573
-
574
- // Delete session
575
- openApiRoute(
576
- app,
577
- {
578
- method: 'delete',
579
- path: '/v1/sessions/{sessionId}',
580
- tags: ['sessions'],
581
- operationId: 'deleteSession',
582
- summary: 'Delete a session',
583
- parameters: [
584
- {
585
- in: 'path',
586
- name: 'sessionId',
587
- required: true,
588
- schema: {
589
- type: 'string',
590
- },
591
- },
592
- {
593
- in: 'query',
594
- name: 'project',
595
- required: false,
596
- schema: {
597
- type: 'string',
598
- },
599
- description:
600
- 'Project root override (defaults to current working directory).',
601
- },
602
- ],
603
- responses: {
604
- '200': {
605
- description: 'OK',
606
- content: {
607
- 'application/json': {
608
- schema: {
609
- type: 'object',
610
- properties: {
611
- success: {
612
- type: 'boolean',
613
- },
614
- },
615
- required: ['success'],
616
- },
617
- },
618
- },
619
- },
620
- '404': {
621
- description: 'Bad Request',
622
- content: {
623
- 'application/json': {
624
- schema: {
625
- type: 'object',
626
- properties: {
627
- error: {
628
- type: 'string',
629
- },
630
- },
631
- required: ['error'],
632
- },
633
- },
634
- },
635
- },
636
- },
637
- },
638
- async (c) => {
639
- try {
640
- const sessionId = c.req.param('sessionId');
641
- const projectRoot = c.req.query('project') || process.cwd();
642
- const cfg = await loadConfig(projectRoot);
643
- const db = await getDb(cfg.projectRoot);
644
-
645
- const existingRows = await db
646
- .select()
647
- .from(sessions)
648
- .where(eq(sessions.id, sessionId))
649
- .limit(1);
650
-
651
- if (!existingRows.length) {
652
- return c.json({ error: 'Session not found' }, 404);
653
- }
654
-
655
- const existingSession = existingRows[0];
656
-
657
- if (existingSession.projectPath !== cfg.projectRoot) {
658
- return c.json({ error: 'Session not found in this project' }, 404);
659
- }
660
-
661
- await db
662
- .delete(messageParts)
663
- .where(
664
- inArray(
665
- messageParts.messageId,
666
- db
667
- .select({ id: messages.id })
668
- .from(messages)
669
- .where(eq(messages.sessionId, sessionId)),
670
- ),
671
- );
672
- await db.delete(messages).where(eq(messages.sessionId, sessionId));
673
- await db.delete(sessions).where(eq(sessions.id, sessionId));
674
-
675
- return c.json({ success: true });
676
- } catch (err) {
677
- logger.error('Failed to delete session', err);
678
- const errorResponse = serializeError(err);
679
- return c.json(errorResponse, errorResponse.error.status || 500);
680
- }
681
- },
682
- );
683
-
684
- // Abort session stream
685
- openApiRoute(
686
- app,
687
- {
688
- method: 'delete',
689
- path: '/v1/sessions/{sessionId}/abort',
690
- tags: ['sessions'],
691
- operationId: 'abortSession',
692
- summary: 'Abort a running session',
693
- description:
694
- 'Aborts any currently running assistant generation for the session',
695
- parameters: [
696
- {
697
- in: 'path',
698
- name: 'sessionId',
699
- required: true,
700
- schema: {
701
- type: 'string',
702
- },
703
- description: 'Session ID to abort',
704
- },
705
- ],
706
- responses: {
707
- '200': {
708
- description: 'OK',
709
- content: {
710
- 'application/json': {
711
- schema: {
712
- type: 'object',
713
- properties: {
714
- success: {
715
- type: 'boolean',
716
- },
717
- },
718
- required: ['success'],
719
- },
720
- },
721
- },
722
- },
723
- },
724
- },
725
- async (c) => {
726
- const sessionId = c.req.param('sessionId');
727
- const body = (await c.req.json().catch(() => ({}))) as Record<
728
- string,
729
- unknown
730
- >;
731
- const messageId =
732
- typeof body.messageId === 'string' ? body.messageId : undefined;
733
- const clearQueue = body.clearQueue === true;
734
-
735
- const { abortSession, abortMessage } = await import(
736
- '../runtime/agent/runner.ts'
737
- );
738
-
739
- if (messageId) {
740
- const result = abortMessage(sessionId, messageId);
741
- return c.json({
742
- success: result.removed,
743
- wasRunning: result.wasRunning,
744
- messageId,
745
- });
746
- }
747
-
748
- abortSession(sessionId, clearQueue);
749
- return c.json({ success: true });
750
- },
751
- );
752
-
753
- // Get queue state for a session
754
- openApiRoute(
755
- app,
756
- {
757
- method: 'get',
758
- path: '/v1/sessions/{sessionId}/queue',
759
- tags: ['sessions'],
760
- operationId: 'getSessionQueue',
761
- summary: 'Get queue state for a session',
762
- parameters: [
763
- {
764
- in: 'path',
765
- name: 'sessionId',
766
- required: true,
767
- schema: {
768
- type: 'string',
769
- },
770
- },
771
- ],
772
- responses: {
773
- '200': {
774
- description: 'OK',
775
- content: {
776
- 'application/json': {
777
- schema: {
778
- type: 'object',
779
- properties: {
780
- currentMessageId: {
781
- type: 'string',
782
- nullable: true,
783
- },
784
- queuedMessages: {
785
- type: 'array',
786
- items: {
787
- type: 'object',
788
- properties: {
789
- assistantMessageId: {
790
- type: 'string',
791
- },
792
- agent: {
793
- type: 'string',
794
- },
795
- provider: {
796
- type: 'string',
797
- },
798
- model: {
799
- type: 'string',
800
- },
801
- },
802
- },
803
- },
804
- isRunning: {
805
- type: 'boolean',
806
- },
807
- },
808
- required: ['currentMessageId', 'queuedMessages', 'isRunning'],
809
- },
810
- },
811
- },
812
- },
813
- },
814
- },
815
- async (c) => {
816
- const sessionId = c.req.param('sessionId');
817
- const { getQueueState } = await import('../runtime/session/queue.ts');
818
- const state = getQueueState(sessionId);
819
- return c.json(
820
- state ?? {
821
- currentMessageId: null,
822
- queuedMessages: [],
823
- isRunning: false,
824
- },
825
- );
826
- },
827
- );
828
-
829
- // Remove a message from the queue
830
- openApiRoute(
831
- app,
832
- {
833
- method: 'delete',
834
- path: '/v1/sessions/{sessionId}/queue/{messageId}',
835
- tags: ['sessions'],
836
- operationId: 'removeFromQueue',
837
- summary: 'Remove a message from session queue',
838
- parameters: [
839
- {
840
- in: 'path',
841
- name: 'sessionId',
842
- required: true,
843
- schema: {
844
- type: 'string',
845
- },
846
- },
847
- {
848
- in: 'path',
849
- name: 'messageId',
850
- required: true,
851
- schema: {
852
- type: 'string',
853
- },
854
- },
855
- {
856
- in: 'query',
857
- name: 'project',
858
- required: false,
859
- schema: {
860
- type: 'string',
861
- },
862
- description:
863
- 'Project root override (defaults to current working directory).',
864
- },
865
- ],
866
- responses: {
867
- '200': {
868
- description: 'OK',
869
- content: {
870
- 'application/json': {
871
- schema: {
872
- type: 'object',
873
- properties: {
874
- success: {
875
- type: 'boolean',
876
- },
877
- removed: {
878
- type: 'boolean',
879
- },
880
- wasQueued: {
881
- type: 'boolean',
882
- },
883
- wasRunning: {
884
- type: 'boolean',
885
- },
886
- wasStored: {
887
- type: 'boolean',
888
- },
889
- },
890
- required: ['success'],
891
- },
892
- },
893
- },
894
- },
895
- '404': {
896
- description: 'Bad Request',
897
- content: {
898
- 'application/json': {
899
- schema: {
900
- type: 'object',
901
- properties: {
902
- error: {
903
- type: 'string',
904
- },
905
- },
906
- required: ['error'],
907
- },
908
- },
909
- },
910
- },
911
- },
912
- },
913
- async (c) => {
914
- const sessionId = c.req.param('sessionId');
915
- const messageId = c.req.param('messageId');
916
- const projectRoot = c.req.query('project') || process.cwd();
917
- const cfg = await loadConfig(projectRoot);
918
- const db = await getDb(cfg.projectRoot);
919
- const { removeFromQueue, abortMessage } = await import(
920
- '../runtime/session/queue.ts'
921
- );
922
-
923
- // First try to remove from queue (queued messages)
924
- const removed = removeFromQueue(sessionId, messageId);
925
- if (removed) {
926
- // Delete messages from database
927
- try {
928
- // Find the assistant message to get its creation time
929
- const assistantMsg = await db
930
- .select()
931
- .from(messages)
932
- .where(eq(messages.id, messageId))
933
- .limit(1);
934
-
935
- if (assistantMsg.length > 0) {
936
- // Find the user message that came right before (same session, created just before)
937
- const userMsg = await db
938
- .select()
939
- .from(messages)
940
- .where(
941
- and(
942
- eq(messages.sessionId, sessionId),
943
- eq(messages.role, 'user'),
944
- ),
945
- )
946
- .orderBy(desc(messages.createdAt))
947
- .limit(1);
948
-
949
- const messageIdsToDelete = [messageId];
950
- if (userMsg.length > 0) {
951
- messageIdsToDelete.push(userMsg[0].id);
952
- }
953
-
954
- // Delete message parts first (foreign key constraint)
955
- await db
956
- .delete(messageParts)
957
- .where(inArray(messageParts.messageId, messageIdsToDelete));
958
- // Delete messages
959
- await db
960
- .delete(messages)
961
- .where(inArray(messages.id, messageIdsToDelete));
962
- }
963
- } catch (err) {
964
- logger.error('Failed to delete queued messages from DB', err);
965
- }
966
- return c.json({ success: true, removed: true, wasQueued: true });
967
- }
968
-
969
- // If not in queue, try to abort (might be running)
970
- const result = abortMessage(sessionId, messageId);
971
- if (result.removed) {
972
- return c.json({
973
- success: true,
974
- removed: true,
975
- wasQueued: false,
976
- wasRunning: result.wasRunning,
977
- });
978
- }
979
-
980
- // If not queued or running, try to delete directly from database
981
- // This handles system messages (like injected research context)
982
- try {
983
- const existingMsg = await db
984
- .select()
985
- .from(messages)
986
- .where(
987
- and(eq(messages.id, messageId), eq(messages.sessionId, sessionId)),
988
- )
989
- .limit(1);
990
-
991
- if (existingMsg.length > 0) {
992
- // Delete message parts first (foreign key constraint)
993
- await db
994
- .delete(messageParts)
995
- .where(
996
- and(
997
- eq(messageParts.messageId, messageId),
998
- or(
999
- eq(messageParts.type, 'error'),
1000
- and(
1001
- eq(messageParts.type, 'tool_call'),
1002
- eq(messageParts.toolName, 'finish'),
1003
- ),
1004
- ),
1005
- ),
1006
- );
1007
- // Delete message
1008
- await db.delete(messages).where(eq(messages.id, messageId));
1009
-
1010
- return c.json({ success: true, removed: true, wasStored: true });
1011
- }
1012
- } catch (err) {
1013
- logger.error('Failed to delete message from DB', err);
1014
- return c.json(
1015
- { success: false, error: 'Failed to delete message' },
1016
- 500,
1017
- );
1018
- }
1019
-
1020
- return c.json({ success: false, removed: false }, 404);
1021
- },
1022
- );
1023
-
1024
- openApiRoute(
1025
- app,
1026
- {
1027
- method: 'get',
1028
- path: '/v1/sessions/{sessionId}/share',
1029
- tags: ['sessions'],
1030
- operationId: 'getShareStatus',
1031
- summary: 'Get share status for a session',
1032
- parameters: [
1033
- {
1034
- in: 'path',
1035
- name: 'sessionId',
1036
- required: true,
1037
- schema: {
1038
- type: 'string',
1039
- },
1040
- },
1041
- {
1042
- in: 'query',
1043
- name: 'project',
1044
- required: false,
1045
- schema: {
1046
- type: 'string',
1047
- },
1048
- description:
1049
- 'Project root override (defaults to current working directory).',
1050
- },
1051
- ],
1052
- responses: {
1053
- '200': {
1054
- description: 'OK',
1055
- content: {
1056
- 'application/json': {
1057
- schema: {
1058
- type: 'object',
1059
- properties: {
1060
- shared: {
1061
- type: 'boolean',
1062
- },
1063
- shareId: {
1064
- type: 'string',
1065
- },
1066
- url: {
1067
- type: 'string',
1068
- },
1069
- title: {
1070
- type: 'string',
1071
- nullable: true,
1072
- },
1073
- createdAt: {
1074
- type: 'integer',
1075
- },
1076
- lastSyncedAt: {
1077
- type: 'integer',
1078
- },
1079
- lastSyncedMessageId: {
1080
- type: 'string',
1081
- },
1082
- syncedMessages: {
1083
- type: 'integer',
1084
- },
1085
- totalMessages: {
1086
- type: 'integer',
1087
- },
1088
- pendingMessages: {
1089
- type: 'integer',
1090
- },
1091
- isSynced: {
1092
- type: 'boolean',
1093
- },
1094
- },
1095
- required: ['shared'],
1096
- },
1097
- },
1098
- },
1099
- },
1100
- },
1101
- },
1102
- async (c) => {
1103
- const sessionId = c.req.param('sessionId');
1104
- const projectRoot = c.req.query('project') || process.cwd();
1105
- const cfg = await loadConfig(projectRoot);
1106
- const db = await getDb(cfg.projectRoot);
1107
-
1108
- const share = await db
1109
- .select()
1110
- .from(shares)
1111
- .where(eq(shares.sessionId, sessionId))
1112
- .limit(1);
1113
-
1114
- if (!share.length) {
1115
- return c.json({ shared: false });
1116
- }
1117
-
1118
- const allMessages = await db
1119
- .select({ id: messages.id })
1120
- .from(messages)
1121
- .where(eq(messages.sessionId, sessionId))
1122
- .orderBy(messages.createdAt);
1123
-
1124
- const totalMessages = allMessages.length;
1125
- const syncedIdx = allMessages.findIndex(
1126
- (m) => m.id === share[0].lastSyncedMessageId,
1127
- );
1128
- const syncedMessages = syncedIdx === -1 ? 0 : syncedIdx + 1;
1129
- const pendingMessages = totalMessages - syncedMessages;
1130
-
1131
- return c.json({
1132
- shared: true,
1133
- shareId: share[0].shareId,
1134
- url: share[0].url,
1135
- title: share[0].title,
1136
- createdAt: share[0].createdAt,
1137
- lastSyncedAt: share[0].lastSyncedAt,
1138
- lastSyncedMessageId: share[0].lastSyncedMessageId,
1139
- syncedMessages,
1140
- totalMessages,
1141
- pendingMessages,
1142
- isSynced: pendingMessages === 0,
1143
- });
1144
- },
1145
- );
1146
-
1147
- const SHARE_API_URL =
1148
- process.env.OTTO_SHARE_API_URL || 'https://api.share.ottocode.io';
1149
-
1150
- function getUsername(): string {
1151
- try {
1152
- return userInfo().username;
1153
- } catch {
1154
- return 'anonymous';
1155
- }
1156
- }
1157
-
1158
- openApiRoute(
1159
- app,
1160
- {
1161
- method: 'post',
1162
- path: '/v1/sessions/{sessionId}/share',
1163
- tags: ['sessions'],
1164
- operationId: 'shareSession',
1165
- summary: 'Share a session',
1166
- parameters: [
1167
- {
1168
- in: 'path',
1169
- name: 'sessionId',
1170
- required: true,
1171
- schema: {
1172
- type: 'string',
1173
- },
1174
- },
1175
- {
1176
- in: 'query',
1177
- name: 'project',
1178
- required: false,
1179
- schema: {
1180
- type: 'string',
1181
- },
1182
- description:
1183
- 'Project root override (defaults to current working directory).',
1184
- },
1185
- ],
1186
- responses: {
1187
- '200': {
1188
- description: 'OK',
1189
- content: {
1190
- 'application/json': {
1191
- schema: {
1192
- type: 'object',
1193
- properties: {
1194
- shared: {
1195
- type: 'boolean',
1196
- },
1197
- shareId: {
1198
- type: 'string',
1199
- },
1200
- url: {
1201
- type: 'string',
1202
- },
1203
- message: {
1204
- type: 'string',
1205
- },
1206
- },
1207
- required: ['shared'],
1208
- },
1209
- },
1210
- },
1211
- },
1212
- '400': {
1213
- description: 'Bad Request',
1214
- content: {
1215
- 'application/json': {
1216
- schema: {
1217
- type: 'object',
1218
- properties: {
1219
- error: {
1220
- type: 'string',
1221
- },
1222
- },
1223
- required: ['error'],
1224
- },
1225
- },
1226
- },
1227
- },
1228
- '404': {
1229
- description: 'Bad Request',
1230
- content: {
1231
- 'application/json': {
1232
- schema: {
1233
- type: 'object',
1234
- properties: {
1235
- error: {
1236
- type: 'string',
1237
- },
1238
- },
1239
- required: ['error'],
1240
- },
1241
- },
1242
- },
1243
- },
1244
- },
1245
- },
1246
- async (c) => {
1247
- const sessionId = c.req.param('sessionId');
1248
- const projectRoot = c.req.query('project') || process.cwd();
1249
- const cfg = await loadConfig(projectRoot);
1250
- const db = await getDb(cfg.projectRoot);
1251
-
1252
- const session = await db
1253
- .select()
1254
- .from(sessions)
1255
- .where(eq(sessions.id, sessionId))
1256
- .limit(1);
1257
- if (!session.length) {
1258
- return c.json({ error: 'Session not found' }, 404);
1259
- }
1260
-
1261
- const existingShare = await db
1262
- .select()
1263
- .from(shares)
1264
- .where(eq(shares.sessionId, sessionId))
1265
- .limit(1);
1266
- if (existingShare.length) {
1267
- return c.json({
1268
- shared: true,
1269
- shareId: existingShare[0].shareId,
1270
- url: existingShare[0].url,
1271
- message: 'Already shared',
1272
- });
1273
- }
1274
-
1275
- const allMessages = await db
1276
- .select()
1277
- .from(messages)
1278
- .where(eq(messages.sessionId, sessionId))
1279
- .orderBy(messages.createdAt);
1280
-
1281
- if (!allMessages.length) {
1282
- return c.json({ error: 'Session has no messages' }, 400);
1283
- }
1284
-
1285
- const msgParts = await db
1286
- .select()
1287
- .from(messageParts)
1288
- .where(
1289
- inArray(
1290
- messageParts.messageId,
1291
- allMessages.map((m) => m.id),
1292
- ),
1293
- )
1294
- .orderBy(messageParts.index);
1295
-
1296
- const partsByMessage = new Map<string, typeof msgParts>();
1297
- for (const part of msgParts) {
1298
- const list = partsByMessage.get(part.messageId) || [];
1299
- list.push(part);
1300
- partsByMessage.set(part.messageId, list);
1301
- }
1302
-
1303
- const lastMessageId = allMessages[allMessages.length - 1].id;
1304
- const sess = session[0];
1305
-
1306
- const sessionData = {
1307
- title: sess.title,
1308
- username: getUsername(),
1309
- agent: sess.agent,
1310
- provider: sess.provider,
1311
- model: sess.model,
1312
- createdAt: sess.createdAt,
1313
- stats: {
1314
- inputTokens: sess.totalInputTokens ?? 0,
1315
- outputTokens: sess.totalOutputTokens ?? 0,
1316
- cachedTokens: sess.totalCachedTokens ?? 0,
1317
- cacheCreationTokens: sess.totalCacheCreationTokens ?? 0,
1318
- reasoningTokens: sess.totalReasoningTokens ?? 0,
1319
- toolTimeMs: sess.totalToolTimeMs ?? 0,
1320
- toolCounts: sess.toolCountsJson
1321
- ? JSON.parse(sess.toolCountsJson)
1322
- : {},
1323
- },
1324
- messages: allMessages.map((m) => ({
1325
- id: m.id,
1326
- role: m.role,
1327
- createdAt: m.createdAt,
1328
- parts: (partsByMessage.get(m.id) || []).map((p) => ({
1329
- type: p.type,
1330
- content: p.content,
1331
- toolName: p.toolName,
1332
- toolCallId: p.toolCallId,
1333
- })),
1334
- })),
1335
- };
1336
-
1337
- const res = await fetch(`${SHARE_API_URL}/share`, {
1338
- method: 'POST',
1339
- headers: { 'Content-Type': 'application/json' },
1340
- body: JSON.stringify({
1341
- sessionData,
1342
- title: sess.title,
1343
- lastMessageId,
1344
- }),
1345
- });
1346
-
1347
- if (!res.ok) {
1348
- const err = await res.text();
1349
- return c.json({ error: `Failed to create share: ${err}` }, 500);
1350
- }
1351
-
1352
- const data = (await res.json()) as {
1353
- shareId: string;
1354
- secret: string;
1355
- url: string;
1356
- };
1357
-
1358
- await db.insert(shares).values({
1359
- sessionId,
1360
- shareId: data.shareId,
1361
- secret: data.secret,
1362
- url: data.url,
1363
- title: sess.title,
1364
- description: null,
1365
- createdAt: Date.now(),
1366
- lastSyncedAt: Date.now(),
1367
- lastSyncedMessageId: lastMessageId,
1368
- });
1369
-
1370
- return c.json({
1371
- shared: true,
1372
- shareId: data.shareId,
1373
- url: data.url,
1374
- });
1375
- },
1376
- );
1377
-
1378
- openApiRoute(
1379
- app,
1380
- {
1381
- method: 'put',
1382
- path: '/v1/sessions/{sessionId}/share',
1383
- tags: ['sessions'],
1384
- operationId: 'syncShare',
1385
- summary: 'Sync shared session with new messages',
1386
- parameters: [
1387
- {
1388
- in: 'path',
1389
- name: 'sessionId',
1390
- required: true,
1391
- schema: {
1392
- type: 'string',
1393
- },
1394
- },
1395
- {
1396
- in: 'query',
1397
- name: 'project',
1398
- required: false,
1399
- schema: {
1400
- type: 'string',
1401
- },
1402
- description:
1403
- 'Project root override (defaults to current working directory).',
1404
- },
1405
- ],
1406
- responses: {
1407
- '200': {
1408
- description: 'OK',
1409
- content: {
1410
- 'application/json': {
1411
- schema: {
1412
- type: 'object',
1413
- properties: {
1414
- synced: {
1415
- type: 'boolean',
1416
- },
1417
- url: {
1418
- type: 'string',
1419
- },
1420
- newMessages: {
1421
- type: 'integer',
1422
- },
1423
- message: {
1424
- type: 'string',
1425
- },
1426
- },
1427
- required: ['synced'],
1428
- },
1429
- },
1430
- },
1431
- },
1432
- '400': {
1433
- description: 'Bad Request',
1434
- content: {
1435
- 'application/json': {
1436
- schema: {
1437
- type: 'object',
1438
- properties: {
1439
- error: {
1440
- type: 'string',
1441
- },
1442
- },
1443
- required: ['error'],
1444
- },
1445
- },
1446
- },
1447
- },
1448
- },
1449
- },
1450
- async (c) => {
1451
- const sessionId = c.req.param('sessionId');
1452
- const projectRoot = c.req.query('project') || process.cwd();
1453
- const cfg = await loadConfig(projectRoot);
1454
- const db = await getDb(cfg.projectRoot);
1455
-
1456
- const share = await db
1457
- .select()
1458
- .from(shares)
1459
- .where(eq(shares.sessionId, sessionId))
1460
- .limit(1);
1461
- if (!share.length) {
1462
- return c.json({ error: 'Session not shared. Use share first.' }, 400);
1463
- }
1464
-
1465
- const session = await db
1466
- .select()
1467
- .from(sessions)
1468
- .where(eq(sessions.id, sessionId))
1469
- .limit(1);
1470
- if (!session.length) {
1471
- return c.json({ error: 'Session not found' }, 404);
1472
- }
1473
-
1474
- const allMessages = await db
1475
- .select()
1476
- .from(messages)
1477
- .where(eq(messages.sessionId, sessionId))
1478
- .orderBy(messages.createdAt);
1479
-
1480
- const msgParts = await db
1481
- .select()
1482
- .from(messageParts)
1483
- .where(
1484
- inArray(
1485
- messageParts.messageId,
1486
- allMessages.map((m) => m.id),
1487
- ),
1488
- )
1489
- .orderBy(messageParts.index);
1490
-
1491
- const partsByMessage = new Map<string, typeof msgParts>();
1492
- for (const part of msgParts) {
1493
- const list = partsByMessage.get(part.messageId) || [];
1494
- list.push(part);
1495
- partsByMessage.set(part.messageId, list);
1496
- }
1497
-
1498
- const lastSyncedIdx = allMessages.findIndex(
1499
- (m) => m.id === share[0].lastSyncedMessageId,
1500
- );
1501
- const newMessages =
1502
- lastSyncedIdx === -1
1503
- ? allMessages
1504
- : allMessages.slice(lastSyncedIdx + 1);
1505
- const lastMessageId =
1506
- allMessages[allMessages.length - 1]?.id ?? share[0].lastSyncedMessageId;
1507
-
1508
- if (newMessages.length === 0) {
1509
- return c.json({
1510
- synced: true,
1511
- url: share[0].url,
1512
- newMessages: 0,
1513
- message: 'Already synced',
1514
- });
1515
- }
1516
-
1517
- const sess = session[0];
1518
- const sessionData = {
1519
- title: sess.title,
1520
- username: getUsername(),
1521
- agent: sess.agent,
1522
- provider: sess.provider,
1523
- model: sess.model,
1524
- createdAt: sess.createdAt,
1525
- stats: {
1526
- inputTokens: sess.totalInputTokens ?? 0,
1527
- outputTokens: sess.totalOutputTokens ?? 0,
1528
- cachedTokens: sess.totalCachedTokens ?? 0,
1529
- cacheCreationTokens: sess.totalCacheCreationTokens ?? 0,
1530
- reasoningTokens: sess.totalReasoningTokens ?? 0,
1531
- toolTimeMs: sess.totalToolTimeMs ?? 0,
1532
- toolCounts: sess.toolCountsJson
1533
- ? JSON.parse(sess.toolCountsJson)
1534
- : {},
1535
- },
1536
- messages: allMessages.map((m) => ({
1537
- id: m.id,
1538
- role: m.role,
1539
- createdAt: m.createdAt,
1540
- parts: (partsByMessage.get(m.id) || []).map((p) => ({
1541
- type: p.type,
1542
- content: p.content,
1543
- toolName: p.toolName,
1544
- toolCallId: p.toolCallId,
1545
- })),
1546
- })),
1547
- };
1548
-
1549
- const res = await fetch(`${SHARE_API_URL}/share/${share[0].shareId}`, {
1550
- method: 'PUT',
1551
- headers: {
1552
- 'Content-Type': 'application/json',
1553
- 'X-Share-Secret': share[0].secret,
1554
- },
1555
- body: JSON.stringify({
1556
- sessionData,
1557
- title: sess.title,
1558
- lastMessageId,
1559
- }),
1560
- });
1561
-
1562
- if (!res.ok) {
1563
- const err = await res.text();
1564
- return c.json({ error: `Failed to sync share: ${err}` }, 500);
1565
- }
1566
-
1567
- await db
1568
- .update(shares)
1569
- .set({
1570
- title: sess.title,
1571
- lastSyncedAt: Date.now(),
1572
- lastSyncedMessageId: lastMessageId,
1573
- })
1574
- .where(eq(shares.sessionId, sessionId));
1575
-
1576
- return c.json({
1577
- synced: true,
1578
- url: share[0].url,
1579
- newMessages: newMessages.length,
1580
- });
1581
- },
1582
- );
1583
-
1584
- openApiRoute(
1585
- app,
1586
- {
1587
- method: 'delete',
1588
- path: '/v1/sessions/{sessionId}/share',
1589
- tags: ['sessions'],
1590
- operationId: 'deleteShare',
1591
- summary: 'Delete a shared session',
1592
- parameters: [
1593
- {
1594
- in: 'path',
1595
- name: 'sessionId',
1596
- required: true,
1597
- schema: {
1598
- type: 'string',
1599
- },
1600
- },
1601
- {
1602
- in: 'query',
1603
- name: 'project',
1604
- required: false,
1605
- schema: {
1606
- type: 'string',
1607
- },
1608
- description:
1609
- 'Project root override (defaults to current working directory).',
1610
- },
1611
- ],
1612
- responses: {
1613
- '200': {
1614
- description: 'OK',
1615
- content: {
1616
- 'application/json': {
1617
- schema: {
1618
- type: 'object',
1619
- properties: {
1620
- deleted: {
1621
- type: 'boolean',
1622
- },
1623
- sessionId: {
1624
- type: 'string',
1625
- },
1626
- },
1627
- required: ['deleted', 'sessionId'],
1628
- },
1629
- },
1630
- },
1631
- },
1632
- '404': {
1633
- description: 'Bad Request',
1634
- content: {
1635
- 'application/json': {
1636
- schema: {
1637
- type: 'object',
1638
- properties: {
1639
- error: {
1640
- type: 'string',
1641
- },
1642
- },
1643
- required: ['error'],
1644
- },
1645
- },
1646
- },
1647
- },
1648
- },
1649
- },
1650
- async (c) => {
1651
- const sessionId = c.req.param('sessionId');
1652
- const projectRoot = c.req.query('project') || process.cwd();
1653
- const cfg = await loadConfig(projectRoot);
1654
- const db = await getDb(cfg.projectRoot);
1655
-
1656
- const share = await db
1657
- .select()
1658
- .from(shares)
1659
- .where(eq(shares.sessionId, sessionId))
1660
- .limit(1);
1661
-
1662
- if (!share.length) {
1663
- return c.json({ error: 'Session is not shared' }, 404);
1664
- }
1665
-
1666
- try {
1667
- const res = await fetch(`${SHARE_API_URL}/share/${share[0].shareId}`, {
1668
- method: 'DELETE',
1669
- headers: { 'X-Share-Secret': share[0].secret },
1670
- });
1671
-
1672
- if (!res.ok && res.status !== 404) {
1673
- const err = await res.text();
1674
- return c.json({ error: `Failed to delete share: ${err}` }, 500);
1675
- }
1676
- } catch {}
1677
-
1678
- await db.delete(shares).where(eq(shares.sessionId, sessionId));
1679
-
1680
- return c.json({ deleted: true, sessionId });
1681
- },
1682
- );
1683
-
1684
- openApiRoute(
1685
- app,
1686
- {
1687
- method: 'get',
1688
- path: '/v1/shares',
1689
- tags: ['sessions'],
1690
- operationId: 'listShares',
1691
- summary: 'List all shared sessions for a project',
1692
- parameters: [
1693
- {
1694
- in: 'query',
1695
- name: 'project',
1696
- required: false,
1697
- schema: {
1698
- type: 'string',
1699
- },
1700
- description:
1701
- 'Project root override (defaults to current working directory).',
1702
- },
1703
- ],
1704
- responses: {
1705
- '200': {
1706
- description: 'OK',
1707
- content: {
1708
- 'application/json': {
1709
- schema: {
1710
- type: 'object',
1711
- properties: {
1712
- shares: {
1713
- type: 'array',
1714
- items: {
1715
- type: 'object',
1716
- properties: {
1717
- sessionId: {
1718
- type: 'string',
1719
- },
1720
- shareId: {
1721
- type: 'string',
1722
- },
1723
- url: {
1724
- type: 'string',
1725
- },
1726
- title: {
1727
- type: 'string',
1728
- nullable: true,
1729
- },
1730
- createdAt: {
1731
- type: 'integer',
1732
- },
1733
- lastSyncedAt: {
1734
- type: 'integer',
1735
- },
1736
- },
1737
- required: [
1738
- 'sessionId',
1739
- 'shareId',
1740
- 'url',
1741
- 'createdAt',
1742
- 'lastSyncedAt',
1743
- ],
1744
- },
1745
- },
1746
- },
1747
- required: ['shares'],
1748
- },
1749
- },
1750
- },
1751
- },
1752
- },
1753
- },
1754
- async (c) => {
1755
- const projectRoot = c.req.query('project') || process.cwd();
1756
- const cfg = await loadConfig(projectRoot);
1757
- const db = await getDb(cfg.projectRoot);
1758
-
1759
- const rows = await db
1760
- .select({
1761
- sessionId: shares.sessionId,
1762
- shareId: shares.shareId,
1763
- url: shares.url,
1764
- title: shares.title,
1765
- createdAt: shares.createdAt,
1766
- lastSyncedAt: shares.lastSyncedAt,
1767
- })
1768
- .from(shares)
1769
- .innerJoin(sessions, eq(shares.sessionId, sessions.id))
1770
- .where(eq(sessions.projectPath, cfg.projectRoot))
1771
- .orderBy(desc(shares.lastSyncedAt));
1772
-
1773
- return c.json({ shares: rows });
1774
- },
1775
- );
1776
-
1777
- // Retry a failed assistant message
1778
- openApiRoute(
1779
- app,
1780
- {
1781
- method: 'post',
1782
- path: '/v1/sessions/{sessionId}/messages/{messageId}/retry',
1783
- tags: ['sessions'],
1784
- operationId: 'retryMessage',
1785
- summary: 'Retry a failed assistant message',
1786
- parameters: [
1787
- {
1788
- in: 'path',
1789
- name: 'sessionId',
1790
- required: true,
1791
- schema: {
1792
- type: 'string',
1793
- },
1794
- },
1795
- {
1796
- in: 'path',
1797
- name: 'messageId',
1798
- required: true,
1799
- schema: {
1800
- type: 'string',
1801
- },
1802
- },
1803
- {
1804
- in: 'query',
1805
- name: 'project',
1806
- required: false,
1807
- schema: {
1808
- type: 'string',
1809
- },
1810
- description:
1811
- 'Project root override (defaults to current working directory).',
1812
- },
1813
- ],
1814
- responses: {
1815
- '200': {
1816
- description: 'OK',
1817
- content: {
1818
- 'application/json': {
1819
- schema: {
1820
- type: 'object',
1821
- properties: {
1822
- success: {
1823
- type: 'boolean',
1824
- },
1825
- messageId: {
1826
- type: 'string',
1827
- },
1828
- },
1829
- required: ['success', 'messageId'],
1830
- },
1831
- },
1832
- },
1833
- },
1834
- '400': {
1835
- description: 'Bad Request',
1836
- content: {
1837
- 'application/json': {
1838
- schema: {
1839
- type: 'object',
1840
- properties: {
1841
- error: {
1842
- type: 'string',
1843
- },
1844
- },
1845
- required: ['error'],
1846
- },
1847
- },
1848
- },
1849
- },
1850
- '404': {
1851
- description: 'Bad Request',
1852
- content: {
1853
- 'application/json': {
1854
- schema: {
1855
- type: 'object',
1856
- properties: {
1857
- error: {
1858
- type: 'string',
1859
- },
1860
- },
1861
- required: ['error'],
1862
- },
1863
- },
1864
- },
1865
- },
1866
- },
1867
- },
1868
- async (c) => {
1869
- try {
1870
- const sessionId = c.req.param('sessionId');
1871
- const messageId = c.req.param('messageId');
1872
- const projectRoot = c.req.query('project') || process.cwd();
1873
- const cfg = await loadConfig(projectRoot);
1874
- const db = await getDb(cfg.projectRoot);
1875
-
1876
- // Get the assistant message
1877
- const [assistantMsg] = await db
1878
- .select()
1879
- .from(messages)
1880
- .where(
1881
- and(
1882
- eq(messages.id, messageId),
1883
- eq(messages.sessionId, sessionId),
1884
- eq(messages.role, 'assistant'),
1885
- ),
1886
- )
1887
- .limit(1);
1888
-
1889
- if (!assistantMsg) {
1890
- return c.json({ error: 'Message not found' }, 404);
1891
- }
1892
-
1893
- // Only allow retry on error or complete messages
1894
- if (
1895
- assistantMsg.status !== 'error' &&
1896
- assistantMsg.status !== 'complete'
1897
- ) {
1898
- return c.json(
1899
- { error: 'Can only retry error or complete messages' },
1900
- 400,
1901
- );
1902
- }
1903
-
1904
- // Get session for context
1905
- const [session] = await db
1906
- .select()
1907
- .from(sessions)
1908
- .where(eq(sessions.id, sessionId))
1909
- .limit(1);
1910
-
1911
- if (!session) {
1912
- return c.json({ error: 'Session not found' }, 404);
1913
- }
1914
-
1915
- await db
1916
- .delete(messageParts)
1917
- .where(
1918
- and(
1919
- eq(messageParts.messageId, messageId),
1920
- or(
1921
- eq(messageParts.type, 'error'),
1922
- and(
1923
- eq(messageParts.type, 'tool_call'),
1924
- eq(messageParts.toolName, 'finish'),
1925
- ),
1926
- ),
1927
- ),
1928
- );
1929
-
1930
- // Reset message status to pending
1931
- await db
1932
- .update(messages)
1933
- .set({
1934
- status: 'pending',
1935
- error: null,
1936
- errorType: null,
1937
- errorDetails: null,
1938
- completedAt: null,
1939
- })
1940
- .where(eq(messages.id, messageId));
1941
-
1942
- // Emit event so UI updates
1943
- const { publish } = await import('../events/bus.ts');
1944
- publish({
1945
- type: 'message.updated',
1946
- sessionId,
1947
- payload: { id: messageId, status: 'pending' },
1948
- });
1949
-
1950
- // Re-enqueue the assistant run
1951
- const { enqueueAssistantRun } = await import(
1952
- '../runtime/session/queue.ts'
1953
- );
1954
- const { runSessionLoop } = await import('../runtime/agent/runner.ts');
1955
-
1956
- const toolApprovalMode = cfg.defaults.toolApproval ?? 'dangerous';
1957
-
1958
- enqueueAssistantRun(
1959
- {
1960
- sessionId,
1961
- assistantMessageId: messageId,
1962
- agent: assistantMsg.agent ?? 'build',
1963
- provider: (assistantMsg.provider ??
1964
- cfg.defaults.provider) as ProviderId,
1965
- model: assistantMsg.model ?? cfg.defaults.model,
1966
- projectRoot: cfg.projectRoot,
1967
- oneShot: false,
1968
- toolApprovalMode,
1969
- },
1970
- runSessionLoop,
1971
- );
1972
-
1973
- return c.json({ success: true, messageId });
1974
- } catch (err) {
1975
- logger.error('Failed to retry message', err);
1976
- const errorResponse = serializeError(err);
1977
- return c.json(errorResponse, errorResponse.error.status || 500);
1978
- }
1979
- },
1980
- );
8
+ registerSessionCrudRoutes(app);
9
+ registerSessionQueueRoutes(app);
10
+ registerSessionShareRoutes(app);
11
+ registerSessionRetryRoutes(app);
1981
12
  }