@ottocode/server 0.1.259 → 0.1.261

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 (69) hide show
  1. package/package.json +4 -3
  2. package/src/index.ts +5 -4
  3. package/src/openapi/register.ts +92 -0
  4. package/src/openapi/route.ts +22 -0
  5. package/src/routes/ask.ts +210 -99
  6. package/src/routes/auth.ts +1701 -626
  7. package/src/routes/branch.ts +281 -90
  8. package/src/routes/config/agents.ts +79 -32
  9. package/src/routes/config/cwd.ts +46 -14
  10. package/src/routes/config/debug.ts +159 -30
  11. package/src/routes/config/defaults.ts +182 -64
  12. package/src/routes/config/main.ts +109 -73
  13. package/src/routes/config/models.ts +304 -137
  14. package/src/routes/config/providers.ts +462 -166
  15. package/src/routes/config/utils.ts +2 -2
  16. package/src/routes/doctor.ts +395 -161
  17. package/src/routes/files.ts +650 -260
  18. package/src/routes/git/branch.ts +143 -52
  19. package/src/routes/git/commit.ts +347 -141
  20. package/src/routes/git/diff.ts +239 -116
  21. package/src/routes/git/init.ts +103 -23
  22. package/src/routes/git/pull.ts +167 -65
  23. package/src/routes/git/push.ts +222 -117
  24. package/src/routes/git/remote.ts +401 -100
  25. package/src/routes/git/staging.ts +502 -141
  26. package/src/routes/git/status.ts +171 -78
  27. package/src/routes/mcp.ts +1129 -404
  28. package/src/routes/openapi.ts +27 -4
  29. package/src/routes/ottorouter.ts +1221 -389
  30. package/src/routes/provider-usage.ts +153 -36
  31. package/src/routes/research.ts +817 -370
  32. package/src/routes/root.ts +50 -6
  33. package/src/routes/session-approval.ts +228 -54
  34. package/src/routes/session-files.ts +265 -134
  35. package/src/routes/session-messages.ts +330 -150
  36. package/src/routes/session-stream.ts +83 -2
  37. package/src/routes/sessions.ts +1830 -780
  38. package/src/routes/skills.ts +849 -161
  39. package/src/routes/terminals.ts +469 -103
  40. package/src/routes/tunnel.ts +394 -118
  41. package/src/runtime/agent/runner-reasoning.ts +38 -3
  42. package/src/runtime/agent/runner.ts +1 -0
  43. package/src/runtime/ask/service.ts +1 -0
  44. package/src/runtime/message/compaction-limits.ts +3 -3
  45. package/src/runtime/provider/reasoning.ts +18 -7
  46. package/src/runtime/session/db-operations.ts +4 -3
  47. package/src/runtime/utils/token.ts +7 -2
  48. package/src/tools/adapter.ts +21 -0
  49. package/src/openapi/paths/ask.ts +0 -81
  50. package/src/openapi/paths/auth.ts +0 -687
  51. package/src/openapi/paths/branch.ts +0 -102
  52. package/src/openapi/paths/config.ts +0 -485
  53. package/src/openapi/paths/doctor.ts +0 -165
  54. package/src/openapi/paths/files.ts +0 -236
  55. package/src/openapi/paths/git.ts +0 -690
  56. package/src/openapi/paths/mcp.ts +0 -339
  57. package/src/openapi/paths/messages.ts +0 -103
  58. package/src/openapi/paths/ottorouter.ts +0 -594
  59. package/src/openapi/paths/provider-usage.ts +0 -59
  60. package/src/openapi/paths/research.ts +0 -227
  61. package/src/openapi/paths/session-approval.ts +0 -93
  62. package/src/openapi/paths/session-extras.ts +0 -336
  63. package/src/openapi/paths/session-files.ts +0 -91
  64. package/src/openapi/paths/sessions.ts +0 -210
  65. package/src/openapi/paths/skills.ts +0 -377
  66. package/src/openapi/paths/stream.ts +0 -26
  67. package/src/openapi/paths/terminals.ts +0 -226
  68. package/src/openapi/paths/tunnel.ts +0 -163
  69. package/src/openapi/spec.ts +0 -73
@@ -16,480 +16,1133 @@ import { createSession as createSessionRow } from '../runtime/session/manager.ts
16
16
  import { serializeError } from '../runtime/errors/api-error.ts';
17
17
  import { logger } from '@ottocode/sdk';
18
18
  import { getRunnerState } from '../runtime/session/queue.ts';
19
+ import { openApiRoute } from '../openapi/route.ts';
19
20
 
20
21
  export function registerSessionsRoutes(app: Hono) {
21
22
  // List sessions
22
- app.get('/v1/sessions', async (c) => {
23
- const projectRoot = c.req.query('project') || process.cwd();
24
- const limit = Math.min(
25
- Math.max(parseInt(c.req.query('limit') || '50', 10) || 50, 1),
26
- 200,
27
- );
28
- const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
29
- const cfg = await loadConfig(projectRoot);
30
- const db = await getDb(cfg.projectRoot);
31
- // Only return sessions for this project, excluding research sessions
32
- const rows = await db
33
- .select()
34
- .from(sessions)
35
- .where(
36
- and(
37
- eq(sessions.projectPath, cfg.projectRoot),
38
- ne(sessions.sessionType, 'research'),
39
- ),
40
- )
41
- .orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt))
42
- .limit(limit + 1)
43
- .offset(offset);
44
- const hasMore = rows.length > limit;
45
- const page = hasMore ? rows.slice(0, limit) : rows;
46
- const normalized = page.map((r) => {
47
- let counts: Record<string, unknown> | undefined;
48
- if (r.toolCountsJson) {
49
- try {
50
- const parsed = JSON.parse(r.toolCountsJson);
51
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
52
- counts = parsed as Record<string, unknown>;
53
- }
54
- } catch {}
55
- }
56
- const { toolCountsJson: _toolCountsJson, ...rest } = r;
57
- const isRunning = getRunnerState(r.id)?.running ?? false;
58
- const base = counts ? { ...rest, toolCounts: counts } : rest;
59
- return { ...base, isRunning };
60
- });
61
- return c.json({
62
- items: normalized,
63
- hasMore,
64
- nextOffset: hasMore ? offset + limit : null,
65
- });
66
- });
67
-
68
- // Create session
69
- app.post('/v1/sessions', async (c) => {
70
- const projectRoot = c.req.query('project') || process.cwd();
71
- const cfg = await loadConfig(projectRoot);
72
- const db = await getDb(cfg.projectRoot);
73
- const body = (await c.req.json().catch(() => ({}))) as Record<
74
- string,
75
- unknown
76
- >;
77
- const agent = (body.agent as string | undefined) ?? cfg.defaults.agent;
78
- const agentCfg = await resolveAgentConfig(cfg.projectRoot, agent);
79
- const providerCandidate =
80
- typeof body.provider === 'string' ? body.provider : undefined;
81
- const provider: ProviderId = (() => {
82
- if (providerCandidate && hasConfiguredProvider(cfg, providerCandidate))
83
- return providerCandidate;
84
- if (hasConfiguredProvider(cfg, agentCfg.provider))
85
- return agentCfg.provider;
86
- return cfg.defaults.provider;
87
- })();
88
- const modelCandidate =
89
- typeof body.model === 'string' ? body.model.trim() : undefined;
90
- const model = modelCandidate?.length
91
- ? modelCandidate
92
- : (agentCfg.model ?? cfg.defaults.model);
93
- try {
94
- const row = await createSessionRow({
95
- db,
96
- cfg,
97
- agent,
98
- provider,
99
- model,
100
- title: (body.title as string | null | undefined) ?? null,
101
- });
102
- return c.json(row, 201);
103
- } catch (err) {
104
- logger.error('Failed to create session', err);
105
- const errorResponse = serializeError(err);
106
- return c.json(errorResponse, errorResponse.error.status || 400);
107
- }
108
- });
109
-
110
- // Get single session
111
- app.get('/v1/sessions/:sessionId', async (c) => {
112
- try {
113
- const sessionId = c.req.param('sessionId');
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) => {
114
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
+ );
115
103
  const cfg = await loadConfig(projectRoot);
116
104
  const db = await getDb(cfg.projectRoot);
105
+ // Only return sessions for this project, excluding research sessions
117
106
  const rows = await db
118
107
  .select()
119
108
  .from(sessions)
120
- .where(eq(sessions.id, sessionId))
121
- .limit(1);
122
- if (!rows.length) {
123
- return c.json(
124
- { error: { message: 'Session not found', status: 404 } },
125
- 404,
126
- );
127
- }
128
- const r = rows[0];
129
- let counts: Record<string, unknown> | undefined;
130
- if (r.toolCountsJson) {
131
- try {
132
- const parsed = JSON.parse(r.toolCountsJson);
133
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
134
- counts = parsed as Record<string, unknown>;
135
- }
136
- } catch {}
137
- }
138
- const { toolCountsJson: _toolCountsJson, ...rest } = r;
139
- return c.json(counts ? { ...rest, toolCounts: counts } : rest);
140
- } catch (err) {
141
- logger.error('Failed to get session', err);
142
- const errorResponse = serializeError(err);
143
- return c.json(errorResponse, errorResponse.error.status || 500);
144
- }
145
- });
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
146
 
147
- // Update session preferences
148
- app.patch('/v1/sessions/:sessionId', async (c) => {
149
- try {
150
- const sessionId = c.req.param('sessionId');
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) => {
151
223
  const projectRoot = c.req.query('project') || process.cwd();
152
224
  const cfg = await loadConfig(projectRoot);
153
225
  const db = await getDb(cfg.projectRoot);
154
-
155
226
  const body = (await c.req.json().catch(() => ({}))) as Record<
156
227
  string,
157
228
  unknown
158
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
+ );
159
263
 
160
- // Fetch existing session
161
- const existingRows = await db
162
- .select()
163
- .from(sessions)
164
- .where(eq(sessions.id, sessionId))
165
- .limit(1);
166
-
167
- if (!existingRows.length) {
168
- return c.json({ error: 'Session not found' }, 404);
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);
169
359
  }
360
+ },
361
+ );
170
362
 
171
- const existingSession = existingRows[0];
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);
172
479
 
173
- // Verify session belongs to current project
174
- if (existingSession.projectPath !== cfg.projectRoot) {
175
- return c.json({ error: 'Session not found in this project' }, 404);
176
- }
480
+ if (!existingRows.length) {
481
+ return c.json({ error: 'Session not found' }, 404);
482
+ }
177
483
 
178
- // Prepare update data
179
- const updates: {
180
- agent?: string;
181
- provider?: string;
182
- model?: string;
183
- title?: string | null;
184
- lastActiveAt?: number;
185
- } = {
186
- lastActiveAt: Date.now(),
187
- };
484
+ const existingSession = existingRows[0];
188
485
 
189
- if (typeof body.title === 'string') {
190
- updates.title = body.title.trim() || null;
191
- }
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
+ }
192
490
 
193
- // Validate agent if provided
194
- if (typeof body.agent === 'string') {
195
- const agentName = body.agent.trim();
196
- if (agentName) {
197
- // Agent validation: check if it exists via resolveAgentConfig
198
- try {
199
- await resolveAgentConfig(cfg.projectRoot, agentName);
200
- updates.agent = agentName;
201
- } catch (err) {
202
- logger.warn('Invalid agent provided', { agent: agentName, err });
203
- return c.json({ error: `Invalid agent: ${agentName}` }, 400);
204
- }
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;
205
504
  }
206
- }
207
505
 
208
- // Validate provider if provided
209
- if (typeof body.provider === 'string') {
210
- const providerName = body.provider.trim();
211
- if (providerName && hasConfiguredProvider(cfg, providerName)) {
212
- updates.provider = providerName;
213
- } else if (providerName) {
214
- return c.json({ error: `Invalid provider: ${providerName}` }, 400);
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
+ }
215
519
  }
216
- }
217
520
 
218
- // Validate model if provided (and optionally verify it belongs to provider)
219
- if (typeof body.model === 'string') {
220
- const modelName = body.model.trim();
221
- if (modelName) {
222
- const targetProvider = (updates.provider ||
223
- existingSession.provider) as ProviderId;
224
- try {
225
- validateProviderModel(targetProvider, modelName, cfg);
226
- } catch {
227
- return c.json(
228
- {
229
- error: `Model "${modelName}" not found for provider "${targetProvider}"`,
230
- },
231
- 400,
232
- );
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);
233
528
  }
529
+ }
234
530
 
235
- updates.model = modelName;
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
+ }
236
550
  }
237
- }
238
551
 
239
- // Perform update
240
- await db.update(sessions).set(updates).where(eq(sessions.id, sessionId));
552
+ // Perform update
553
+ await db
554
+ .update(sessions)
555
+ .set(updates)
556
+ .where(eq(sessions.id, sessionId));
241
557
 
242
- // Return updated session
243
- const updatedRows = await db
244
- .select()
245
- .from(sessions)
246
- .where(eq(sessions.id, sessionId))
247
- .limit(1);
558
+ // Return updated session
559
+ const updatedRows = await db
560
+ .select()
561
+ .from(sessions)
562
+ .where(eq(sessions.id, sessionId))
563
+ .limit(1);
248
564
 
249
- return c.json(updatedRows[0]);
250
- } catch (err) {
251
- logger.error('Failed to update session', err);
252
- const errorResponse = serializeError(err);
253
- return c.json(errorResponse, errorResponse.error.status || 500);
254
- }
255
- });
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
+ );
256
573
 
257
574
  // Delete session
258
- app.delete('/v1/sessions/:sessionId', async (c) => {
259
- try {
260
- const sessionId = c.req.param('sessionId');
261
- const projectRoot = c.req.query('project') || process.cwd();
262
- const cfg = await loadConfig(projectRoot);
263
- const db = await getDb(cfg.projectRoot);
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);
264
644
 
265
- const existingRows = await db
266
- .select()
267
- .from(sessions)
268
- .where(eq(sessions.id, sessionId))
269
- .limit(1);
645
+ const existingRows = await db
646
+ .select()
647
+ .from(sessions)
648
+ .where(eq(sessions.id, sessionId))
649
+ .limit(1);
270
650
 
271
- if (!existingRows.length) {
272
- return c.json({ error: 'Session not found' }, 404);
273
- }
651
+ if (!existingRows.length) {
652
+ return c.json({ error: 'Session not found' }, 404);
653
+ }
274
654
 
275
- const existingSession = existingRows[0];
655
+ const existingSession = existingRows[0];
276
656
 
277
- if (existingSession.projectPath !== cfg.projectRoot) {
278
- return c.json({ error: 'Session not found in this project' }, 404);
279
- }
657
+ if (existingSession.projectPath !== cfg.projectRoot) {
658
+ return c.json({ error: 'Session not found in this project' }, 404);
659
+ }
280
660
 
281
- await db
282
- .delete(messageParts)
283
- .where(
284
- inArray(
285
- messageParts.messageId,
286
- db
287
- .select({ id: messages.id })
288
- .from(messages)
289
- .where(eq(messages.sessionId, sessionId)),
290
- ),
291
- );
292
- await db.delete(messages).where(eq(messages.sessionId, sessionId));
293
- await db.delete(sessions).where(eq(sessions.id, sessionId));
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));
294
674
 
295
- return c.json({ success: true });
296
- } catch (err) {
297
- logger.error('Failed to delete session', err);
298
- const errorResponse = serializeError(err);
299
- return c.json(errorResponse, errorResponse.error.status || 500);
300
- }
301
- });
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
+ );
302
683
 
303
684
  // Abort session stream
304
- app.delete('/v1/sessions/:sessionId/abort', async (c) => {
305
- const sessionId = c.req.param('sessionId');
306
- const body = (await c.req.json().catch(() => ({}))) as Record<
307
- string,
308
- unknown
309
- >;
310
- const messageId =
311
- typeof body.messageId === 'string' ? body.messageId : undefined;
312
- const clearQueue = body.clearQueue === true;
313
-
314
- const { abortSession, abortMessage } = await import(
315
- '../runtime/agent/runner.ts'
316
- );
317
-
318
- if (messageId) {
319
- const result = abortMessage(sessionId, messageId);
320
- return c.json({
321
- success: result.removed,
322
- wasRunning: result.wasRunning,
323
- messageId,
324
- });
325
- }
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;
326
734
 
327
- abortSession(sessionId, clearQueue);
328
- return c.json({ success: true });
329
- });
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
+ );
330
752
 
331
753
  // Get queue state for a session
332
- app.get('/v1/sessions/:sessionId/queue', async (c) => {
333
- const sessionId = c.req.param('sessionId');
334
- const { getQueueState } = await import('../runtime/session/queue.ts');
335
- const state = getQueueState(sessionId);
336
- return c.json(
337
- state ?? {
338
- currentMessageId: null,
339
- queuedMessages: [],
340
- isRunning: false,
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
+ },
341
813
  },
342
- );
343
- });
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
+ );
344
828
 
345
829
  // Remove a message from the queue
346
- app.delete('/v1/sessions/:sessionId/queue/:messageId', async (c) => {
347
- const sessionId = c.req.param('sessionId');
348
- const messageId = c.req.param('messageId');
349
- const projectRoot = c.req.query('project') || process.cwd();
350
- const cfg = await loadConfig(projectRoot);
351
- const db = await getDb(cfg.projectRoot);
352
- const { removeFromQueue, abortMessage } = await import(
353
- '../runtime/session/queue.ts'
354
- );
355
-
356
- // First try to remove from queue (queued messages)
357
- const removed = removeFromQueue(sessionId, messageId);
358
- if (removed) {
359
- // Delete messages from database
360
- try {
361
- // Find the assistant message to get its creation time
362
- const assistantMsg = await db
363
- .select()
364
- .from(messages)
365
- .where(eq(messages.id, messageId))
366
- .limit(1);
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
+ );
367
922
 
368
- if (assistantMsg.length > 0) {
369
- // Find the user message that came right before (same session, created just before)
370
- const userMsg = await db
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
371
930
  .select()
372
931
  .from(messages)
373
- .where(
374
- and(eq(messages.sessionId, sessionId), eq(messages.role, 'user')),
375
- )
376
- .orderBy(desc(messages.createdAt))
932
+ .where(eq(messages.id, messageId))
377
933
  .limit(1);
378
934
 
379
- const messageIdsToDelete = [messageId];
380
- if (userMsg.length > 0) {
381
- messageIdsToDelete.push(userMsg[0].id);
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));
382
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);
383
990
 
991
+ if (existingMsg.length > 0) {
384
992
  // Delete message parts first (foreign key constraint)
385
993
  await db
386
994
  .delete(messageParts)
387
- .where(inArray(messageParts.messageId, messageIdsToDelete));
388
- // Delete messages
389
- await db
390
- .delete(messages)
391
- .where(inArray(messages.id, messageIdsToDelete));
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 });
392
1011
  }
393
1012
  } catch (err) {
394
- logger.error('Failed to delete queued messages from DB', 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
+ );
395
1018
  }
396
- return c.json({ success: true, removed: true, wasQueued: true });
397
- }
398
1019
 
399
- // If not in queue, try to abort (might be running)
400
- const result = abortMessage(sessionId, messageId);
401
- if (result.removed) {
402
- return c.json({
403
- success: true,
404
- removed: true,
405
- wasQueued: false,
406
- wasRunning: result.wasRunning,
407
- });
408
- }
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);
409
1107
 
410
- // If not queued or running, try to delete directly from database
411
- // This handles system messages (like injected research context)
412
- try {
413
- const existingMsg = await db
1108
+ const share = await db
414
1109
  .select()
415
- .from(messages)
416
- .where(
417
- and(eq(messages.id, messageId), eq(messages.sessionId, sessionId)),
418
- )
1110
+ .from(shares)
1111
+ .where(eq(shares.sessionId, sessionId))
419
1112
  .limit(1);
420
1113
 
421
- if (existingMsg.length > 0) {
422
- // Delete message parts first (foreign key constraint)
423
- await db
424
- .delete(messageParts)
425
- .where(
426
- and(
427
- eq(messageParts.messageId, messageId),
428
- or(
429
- eq(messageParts.type, 'error'),
430
- and(
431
- eq(messageParts.type, 'tool_call'),
432
- eq(messageParts.toolName, 'finish'),
433
- ),
434
- ),
435
- ),
436
- );
437
- // Delete message
438
- await db.delete(messages).where(eq(messages.id, messageId));
439
-
440
- return c.json({ success: true, removed: true, wasStored: true });
1114
+ if (!share.length) {
1115
+ return c.json({ shared: false });
441
1116
  }
442
- } catch (err) {
443
- logger.error('Failed to delete message from DB', err);
444
- return c.json({ success: false, error: 'Failed to delete message' }, 500);
445
- }
446
-
447
- return c.json({ success: false, removed: false }, 404);
448
- });
449
1117
 
450
- app.get('/v1/sessions/:sessionId/share', async (c) => {
451
- const sessionId = c.req.param('sessionId');
452
- const projectRoot = c.req.query('project') || process.cwd();
453
- const cfg = await loadConfig(projectRoot);
454
- const db = await getDb(cfg.projectRoot);
455
-
456
- const share = await db
457
- .select()
458
- .from(shares)
459
- .where(eq(shares.sessionId, sessionId))
460
- .limit(1);
1118
+ const allMessages = await db
1119
+ .select({ id: messages.id })
1120
+ .from(messages)
1121
+ .where(eq(messages.sessionId, sessionId))
1122
+ .orderBy(messages.createdAt);
461
1123
 
462
- if (!share.length) {
463
- return c.json({ shared: false });
464
- }
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;
465
1130
 
466
- const allMessages = await db
467
- .select({ id: messages.id })
468
- .from(messages)
469
- .where(eq(messages.sessionId, sessionId))
470
- .orderBy(messages.createdAt);
471
-
472
- const totalMessages = allMessages.length;
473
- const syncedIdx = allMessages.findIndex(
474
- (m) => m.id === share[0].lastSyncedMessageId,
475
- );
476
- const syncedMessages = syncedIdx === -1 ? 0 : syncedIdx + 1;
477
- const pendingMessages = totalMessages - syncedMessages;
478
-
479
- return c.json({
480
- shared: true,
481
- shareId: share[0].shareId,
482
- url: share[0].url,
483
- title: share[0].title,
484
- createdAt: share[0].createdAt,
485
- lastSyncedAt: share[0].lastSyncedAt,
486
- lastSyncedMessageId: share[0].lastSyncedMessageId,
487
- syncedMessages,
488
- totalMessages,
489
- pendingMessages,
490
- isSynced: pendingMessages === 0,
491
- });
492
- });
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
+ );
493
1146
 
494
1147
  const SHARE_API_URL =
495
1148
  process.env.OTTO_SHARE_API_URL || 'https://api.share.ottocode.io';
@@ -502,430 +1155,827 @@ export function registerSessionsRoutes(app: Hono) {
502
1155
  }
503
1156
  }
504
1157
 
505
- app.post('/v1/sessions/:sessionId/share', async (c) => {
506
- const sessionId = c.req.param('sessionId');
507
- const projectRoot = c.req.query('project') || process.cwd();
508
- const cfg = await loadConfig(projectRoot);
509
- const db = await getDb(cfg.projectRoot);
510
-
511
- const session = await db
512
- .select()
513
- .from(sessions)
514
- .where(eq(sessions.id, sessionId))
515
- .limit(1);
516
- if (!session.length) {
517
- return c.json({ error: 'Session not found' }, 404);
518
- }
519
-
520
- const existingShare = await db
521
- .select()
522
- .from(shares)
523
- .where(eq(shares.sessionId, sessionId))
524
- .limit(1);
525
- if (existingShare.length) {
526
- return c.json({
527
- shared: true,
528
- shareId: existingShare[0].shareId,
529
- url: existingShare[0].url,
530
- message: 'Already shared',
531
- });
532
- }
533
-
534
- const allMessages = await db
535
- .select()
536
- .from(messages)
537
- .where(eq(messages.sessionId, sessionId))
538
- .orderBy(messages.createdAt);
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);
539
1251
 
540
- if (!allMessages.length) {
541
- return c.json({ error: 'Session has no messages' }, 400);
542
- }
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
+ }
543
1260
 
544
- const msgParts = await db
545
- .select()
546
- .from(messageParts)
547
- .where(
548
- inArray(
549
- messageParts.messageId,
550
- allMessages.map((m) => m.id),
551
- ),
552
- )
553
- .orderBy(messageParts.index);
554
-
555
- const partsByMessage = new Map<string, typeof msgParts>();
556
- for (const part of msgParts) {
557
- const list = partsByMessage.get(part.messageId) || [];
558
- list.push(part);
559
- partsByMessage.set(part.messageId, list);
560
- }
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
+ }
561
1274
 
562
- const lastMessageId = allMessages[allMessages.length - 1].id;
563
- const sess = session[0];
564
-
565
- const sessionData = {
566
- title: sess.title,
567
- username: getUsername(),
568
- agent: sess.agent,
569
- provider: sess.provider,
570
- model: sess.model,
571
- createdAt: sess.createdAt,
572
- stats: {
573
- inputTokens: sess.totalInputTokens ?? 0,
574
- outputTokens: sess.totalOutputTokens ?? 0,
575
- cachedTokens: sess.totalCachedTokens ?? 0,
576
- cacheCreationTokens: sess.totalCacheCreationTokens ?? 0,
577
- reasoningTokens: sess.totalReasoningTokens ?? 0,
578
- toolTimeMs: sess.totalToolTimeMs ?? 0,
579
- toolCounts: sess.toolCountsJson ? JSON.parse(sess.toolCountsJson) : {},
580
- },
581
- messages: allMessages.map((m) => ({
582
- id: m.id,
583
- role: m.role,
584
- createdAt: m.createdAt,
585
- parts: (partsByMessage.get(m.id) || []).map((p) => ({
586
- type: p.type,
587
- content: p.content,
588
- toolName: p.toolName,
589
- toolCallId: p.toolCallId,
590
- })),
591
- })),
592
- };
593
-
594
- const res = await fetch(`${SHARE_API_URL}/share`, {
595
- method: 'POST',
596
- headers: { 'Content-Type': 'application/json' },
597
- body: JSON.stringify({
598
- sessionData,
599
- title: sess.title,
600
- lastMessageId,
601
- }),
602
- });
1275
+ const allMessages = await db
1276
+ .select()
1277
+ .from(messages)
1278
+ .where(eq(messages.sessionId, sessionId))
1279
+ .orderBy(messages.createdAt);
603
1280
 
604
- if (!res.ok) {
605
- const err = await res.text();
606
- return c.json({ error: `Failed to create share: ${err}` }, 500);
607
- }
1281
+ if (!allMessages.length) {
1282
+ return c.json({ error: 'Session has no messages' }, 400);
1283
+ }
608
1284
 
609
- const data = (await res.json()) as {
610
- shareId: string;
611
- secret: string;
612
- url: string;
613
- };
614
-
615
- await db.insert(shares).values({
616
- sessionId,
617
- shareId: data.shareId,
618
- secret: data.secret,
619
- url: data.url,
620
- title: sess.title,
621
- description: null,
622
- createdAt: Date.now(),
623
- lastSyncedAt: Date.now(),
624
- lastSyncedMessageId: lastMessageId,
625
- });
626
-
627
- return c.json({
628
- shared: true,
629
- shareId: data.shareId,
630
- url: data.url,
631
- });
632
- });
633
-
634
- app.put('/v1/sessions/:sessionId/share', async (c) => {
635
- const sessionId = c.req.param('sessionId');
636
- const projectRoot = c.req.query('project') || process.cwd();
637
- const cfg = await loadConfig(projectRoot);
638
- const db = await getDb(cfg.projectRoot);
639
-
640
- const share = await db
641
- .select()
642
- .from(shares)
643
- .where(eq(shares.sessionId, sessionId))
644
- .limit(1);
645
- if (!share.length) {
646
- return c.json({ error: 'Session not shared. Use share first.' }, 400);
647
- }
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);
648
1295
 
649
- const session = await db
650
- .select()
651
- .from(sessions)
652
- .where(eq(sessions.id, sessionId))
653
- .limit(1);
654
- if (!session.length) {
655
- return c.json({ error: 'Session not found' }, 404);
656
- }
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
+ }
657
1302
 
658
- const allMessages = await db
659
- .select()
660
- .from(messages)
661
- .where(eq(messages.sessionId, sessionId))
662
- .orderBy(messages.createdAt);
663
-
664
- const msgParts = await db
665
- .select()
666
- .from(messageParts)
667
- .where(
668
- inArray(
669
- messageParts.messageId,
670
- allMessages.map((m) => m.id),
671
- ),
672
- )
673
- .orderBy(messageParts.index);
674
-
675
- const partsByMessage = new Map<string, typeof msgParts>();
676
- for (const part of msgParts) {
677
- const list = partsByMessage.get(part.messageId) || [];
678
- list.push(part);
679
- partsByMessage.set(part.messageId, list);
680
- }
1303
+ const lastMessageId = allMessages[allMessages.length - 1].id;
1304
+ const sess = session[0];
681
1305
 
682
- const lastSyncedIdx = allMessages.findIndex(
683
- (m) => m.id === share[0].lastSyncedMessageId,
684
- );
685
- const newMessages =
686
- lastSyncedIdx === -1 ? allMessages : allMessages.slice(lastSyncedIdx + 1);
687
- const lastMessageId =
688
- allMessages[allMessages.length - 1]?.id ?? share[0].lastSyncedMessageId;
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
+ };
689
1336
 
690
- if (newMessages.length === 0) {
691
- return c.json({
692
- synced: true,
693
- url: share[0].url,
694
- newMessages: 0,
695
- message: 'Already synced',
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
+ }),
696
1345
  });
697
- }
698
1346
 
699
- const sess = session[0];
700
- const sessionData = {
701
- title: sess.title,
702
- username: getUsername(),
703
- agent: sess.agent,
704
- provider: sess.provider,
705
- model: sess.model,
706
- createdAt: sess.createdAt,
707
- stats: {
708
- inputTokens: sess.totalInputTokens ?? 0,
709
- outputTokens: sess.totalOutputTokens ?? 0,
710
- cachedTokens: sess.totalCachedTokens ?? 0,
711
- cacheCreationTokens: sess.totalCacheCreationTokens ?? 0,
712
- reasoningTokens: sess.totalReasoningTokens ?? 0,
713
- toolTimeMs: sess.totalToolTimeMs ?? 0,
714
- toolCounts: sess.toolCountsJson ? JSON.parse(sess.toolCountsJson) : {},
715
- },
716
- messages: allMessages.map((m) => ({
717
- id: m.id,
718
- role: m.role,
719
- createdAt: m.createdAt,
720
- parts: (partsByMessage.get(m.id) || []).map((p) => ({
721
- type: p.type,
722
- content: p.content,
723
- toolName: p.toolName,
724
- toolCallId: p.toolCallId,
725
- })),
726
- })),
727
- };
728
-
729
- const res = await fetch(`${SHARE_API_URL}/share/${share[0].shareId}`, {
730
- method: 'PUT',
731
- headers: {
732
- 'Content-Type': 'application/json',
733
- 'X-Share-Secret': share[0].secret,
734
- },
735
- body: JSON.stringify({
736
- sessionData,
737
- title: sess.title,
738
- lastMessageId,
739
- }),
740
- });
1347
+ if (!res.ok) {
1348
+ const err = await res.text();
1349
+ return c.json({ error: `Failed to create share: ${err}` }, 500);
1350
+ }
741
1351
 
742
- if (!res.ok) {
743
- const err = await res.text();
744
- return c.json({ error: `Failed to sync share: ${err}` }, 500);
745
- }
1352
+ const data = (await res.json()) as {
1353
+ shareId: string;
1354
+ secret: string;
1355
+ url: string;
1356
+ };
746
1357
 
747
- await db
748
- .update(shares)
749
- .set({
1358
+ await db.insert(shares).values({
1359
+ sessionId,
1360
+ shareId: data.shareId,
1361
+ secret: data.secret,
1362
+ url: data.url,
750
1363
  title: sess.title,
1364
+ description: null,
1365
+ createdAt: Date.now(),
751
1366
  lastSyncedAt: Date.now(),
752
1367
  lastSyncedMessageId: lastMessageId,
753
- })
754
- .where(eq(shares.sessionId, sessionId));
755
-
756
- return c.json({
757
- synced: true,
758
- url: share[0].url,
759
- newMessages: newMessages.length,
760
- });
761
- });
762
-
763
- app.delete('/v1/sessions/:sessionId/share', async (c) => {
764
- const sessionId = c.req.param('sessionId');
765
- const projectRoot = c.req.query('project') || process.cwd();
766
- const cfg = await loadConfig(projectRoot);
767
- const db = await getDb(cfg.projectRoot);
768
-
769
- const share = await db
770
- .select()
771
- .from(shares)
772
- .where(eq(shares.sessionId, sessionId))
773
- .limit(1);
774
-
775
- if (!share.length) {
776
- return c.json({ error: 'Session is not shared' }, 404);
777
- }
778
-
779
- try {
780
- const res = await fetch(`${SHARE_API_URL}/share/${share[0].shareId}`, {
781
- method: 'DELETE',
782
- headers: { 'X-Share-Secret': share[0].secret },
783
1368
  });
784
1369
 
785
- if (!res.ok && res.status !== 404) {
786
- const err = await res.text();
787
- return c.json({ error: `Failed to delete share: ${err}` }, 500);
788
- }
789
- } catch {}
790
-
791
- await db.delete(shares).where(eq(shares.sessionId, sessionId));
792
-
793
- return c.json({ deleted: true, sessionId });
794
- });
795
-
796
- app.get('/v1/shares', async (c) => {
797
- const projectRoot = c.req.query('project') || process.cwd();
798
- const cfg = await loadConfig(projectRoot);
799
- const db = await getDb(cfg.projectRoot);
800
-
801
- const rows = await db
802
- .select({
803
- sessionId: shares.sessionId,
804
- shareId: shares.shareId,
805
- url: shares.url,
806
- title: shares.title,
807
- createdAt: shares.createdAt,
808
- lastSyncedAt: shares.lastSyncedAt,
809
- })
810
- .from(shares)
811
- .innerJoin(sessions, eq(shares.sessionId, sessions.id))
812
- .where(eq(sessions.projectPath, cfg.projectRoot))
813
- .orderBy(desc(shares.lastSyncedAt));
814
-
815
- return c.json({ shares: rows });
816
- });
817
-
818
- // Retry a failed assistant message
819
- app.post('/v1/sessions/:sessionId/messages/:messageId/retry', async (c) => {
820
- try {
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) => {
821
1451
  const sessionId = c.req.param('sessionId');
822
- const messageId = c.req.param('messageId');
823
1452
  const projectRoot = c.req.query('project') || process.cwd();
824
1453
  const cfg = await loadConfig(projectRoot);
825
1454
  const db = await getDb(cfg.projectRoot);
826
1455
 
827
- // Get the assistant message
828
- const [assistantMsg] = await db
1456
+ const share = await db
829
1457
  .select()
830
- .from(messages)
831
- .where(
832
- and(
833
- eq(messages.id, messageId),
834
- eq(messages.sessionId, sessionId),
835
- eq(messages.role, 'assistant'),
836
- ),
837
- )
1458
+ .from(shares)
1459
+ .where(eq(shares.sessionId, sessionId))
838
1460
  .limit(1);
839
-
840
- if (!assistantMsg) {
841
- return c.json({ error: 'Message not found' }, 404);
1461
+ if (!share.length) {
1462
+ return c.json({ error: 'Session not shared. Use share first.' }, 400);
842
1463
  }
843
1464
 
844
- // Only allow retry on error or complete messages
845
- if (
846
- assistantMsg.status !== 'error' &&
847
- assistantMsg.status !== 'complete'
848
- ) {
849
- return c.json(
850
- { error: 'Can only retry error or complete messages' },
851
- 400,
852
- );
853
- }
854
-
855
- // Get session for context
856
- const [session] = await db
1465
+ const session = await db
857
1466
  .select()
858
1467
  .from(sessions)
859
1468
  .where(eq(sessions.id, sessionId))
860
1469
  .limit(1);
861
-
862
- if (!session) {
1470
+ if (!session.length) {
863
1471
  return c.json({ error: 'Session not found' }, 404);
864
1472
  }
865
1473
 
866
- await db
867
- .delete(messageParts)
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)
868
1483
  .where(
869
- and(
870
- eq(messageParts.messageId, messageId),
871
- or(
872
- eq(messageParts.type, 'error'),
873
- and(
874
- eq(messageParts.type, 'tool_call'),
875
- eq(messageParts.toolName, 'finish'),
876
- ),
877
- ),
1484
+ inArray(
1485
+ messageParts.messageId,
1486
+ allMessages.map((m) => m.id),
878
1487
  ),
879
- );
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
+ }
880
1566
 
881
- // Reset message status to pending
882
1567
  await db
883
- .update(messages)
1568
+ .update(shares)
884
1569
  .set({
885
- status: 'pending',
886
- error: null,
887
- errorType: null,
888
- errorDetails: null,
889
- completedAt: null,
1570
+ title: sess.title,
1571
+ lastSyncedAt: Date.now(),
1572
+ lastSyncedMessageId: lastMessageId,
890
1573
  })
891
- .where(eq(messages.id, messageId));
1574
+ .where(eq(shares.sessionId, sessionId));
892
1575
 
893
- // Emit event so UI updates
894
- const { publish } = await import('../events/bus.ts');
895
- publish({
896
- type: 'message.updated',
897
- sessionId,
898
- payload: { id: messageId, status: 'pending' },
1576
+ return c.json({
1577
+ synced: true,
1578
+ url: share[0].url,
1579
+ newMessages: newMessages.length,
899
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);
900
1655
 
901
- // Re-enqueue the assistant run
902
- const { enqueueAssistantRun } = await import(
903
- '../runtime/session/queue.ts'
904
- );
905
- const { runSessionLoop } = await import('../runtime/agent/runner.ts');
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));
906
1772
 
907
- const toolApprovalMode = cfg.defaults.toolApproval ?? 'dangerous';
1773
+ return c.json({ shares: rows });
1774
+ },
1775
+ );
908
1776
 
909
- enqueueAssistantRun(
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
+ },
910
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',
911
1946
  sessionId,
912
- assistantMessageId: messageId,
913
- agent: assistantMsg.agent ?? 'build',
914
- provider: (assistantMsg.provider ??
915
- cfg.defaults.provider) as ProviderId,
916
- model: assistantMsg.model ?? cfg.defaults.model,
917
- projectRoot: cfg.projectRoot,
918
- oneShot: false,
919
- toolApprovalMode,
920
- },
921
- runSessionLoop,
922
- );
1947
+ payload: { id: messageId, status: 'pending' },
1948
+ });
923
1949
 
924
- return c.json({ success: true, messageId });
925
- } catch (err) {
926
- logger.error('Failed to retry message', err);
927
- const errorResponse = serializeError(err);
928
- return c.json(errorResponse, errorResponse.error.status || 500);
929
- }
930
- });
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
+ );
931
1981
  }