@ottocode/server 0.1.267 → 0.1.268

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/server",
3
- "version": "0.1.267",
3
+ "version": "0.1.268",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -61,8 +61,8 @@
61
61
  "typecheck": "tsc --noEmit"
62
62
  },
63
63
  "dependencies": {
64
- "@ottocode/database": "0.1.267",
65
- "@ottocode/sdk": "0.1.267",
64
+ "@ottocode/database": "0.1.268",
65
+ "@ottocode/sdk": "0.1.268",
66
66
  "@hono/zod-openapi": "^1.1.5",
67
67
  "ai-sdk-ollama": "^3.8.3",
68
68
  "drizzle-orm": "^0.44.5",
package/src/events/bus.ts CHANGED
@@ -1,8 +1,10 @@
1
- import type { OttoEvent } from './types.ts';
1
+ import type { ClientEvent, NotificationEvent, OttoEvent } from './types.ts';
2
2
 
3
3
  type Subscriber = (evt: OttoEvent) => void;
4
+ type ClientSubscriber = (evt: ClientEvent) => void;
4
5
 
5
6
  const subscribers = new Map<string, Set<Subscriber>>(); // sessionId -> subs
7
+ const clientSubscribers = new Set<ClientSubscriber>();
6
8
 
7
9
  function sanitizeBigInt<T>(obj: T): T {
8
10
  if (obj === null || obj === undefined) return obj;
@@ -34,6 +36,24 @@ export function publish(event: OttoEvent) {
34
36
  }
35
37
  }
36
38
 
39
+ export function publishClientEvent(event: ClientEvent) {
40
+ const sanitizedEvent = sanitizeBigInt(event);
41
+ for (const sub of clientSubscribers) {
42
+ try {
43
+ sub(sanitizedEvent);
44
+ } catch (err) {
45
+ console.error(
46
+ `[bus] Client subscriber threw on event ${event.type}:`,
47
+ err instanceof Error ? err.message : String(err),
48
+ );
49
+ }
50
+ }
51
+ }
52
+
53
+ export function publishNotification(payload: NotificationEvent) {
54
+ publishClientEvent({ type: 'notification', payload });
55
+ }
56
+
37
57
  export function subscribe(sessionId: string, handler: Subscriber) {
38
58
  let set = subscribers.get(sessionId);
39
59
  if (!set) {
@@ -46,3 +66,10 @@ export function subscribe(sessionId: string, handler: Subscriber) {
46
66
  if (set && set.size === 0) subscribers.delete(sessionId);
47
67
  };
48
68
  }
69
+
70
+ export function subscribeClientEvents(handler: ClientSubscriber) {
71
+ clientSubscribers.add(handler);
72
+ return () => {
73
+ clientSubscribers.delete(handler);
74
+ };
75
+ }
@@ -34,3 +34,34 @@ export interface OttoEvent<T = unknown> {
34
34
  sessionId: string;
35
35
  payload?: T;
36
36
  }
37
+
38
+ export type NotificationLevel = 'info' | 'success' | 'warning' | 'error';
39
+
40
+ export interface NotificationAction {
41
+ label: string;
42
+ href: string;
43
+ }
44
+
45
+ export interface NotificationEvent {
46
+ id: string;
47
+ level: NotificationLevel;
48
+ title: string;
49
+ body?: string;
50
+ action?: NotificationAction;
51
+ createdAt: string;
52
+ expiresAt?: string;
53
+ source?: 'agent' | 'system' | 'session' | 'auth' | 'billing';
54
+ sessionId?: string;
55
+ }
56
+
57
+ export interface SessionStatusEvent {
58
+ sessionId: string;
59
+ status: 'running' | 'completed' | 'failed' | 'needs_attention';
60
+ messageId?: string;
61
+ createdAt: string;
62
+ }
63
+
64
+ export type ClientEvent =
65
+ | { type: 'notification'; payload: NotificationEvent }
66
+ | { type: 'session.status'; payload: SessionStatusEvent }
67
+ | { type: 'heartbeat'; payload: { createdAt: string } };
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { registerOpenApiRoute } from './routes/openapi.ts';
9
9
  import { registerSessionsRoutes } from './routes/sessions.ts';
10
10
  import { registerSessionMessagesRoutes } from './routes/session-messages.ts';
11
11
  import { registerSessionStreamRoute } from './routes/session-stream.ts';
12
+ import { registerClientEventsRoute } from './routes/client-events.ts';
12
13
  import { registerAskRoutes } from './routes/ask.ts';
13
14
  import { registerConfigRoutes } from './routes/config/index.ts';
14
15
  import { registerFilesRoutes } from './routes/files.ts';
@@ -34,8 +35,23 @@ setTerminalManager(globalTerminalManager);
34
35
  // Suppress noisy AI SDK provider warnings unless debug mode is enabled.
35
36
  installAiSdkWarningHandler();
36
37
 
38
+ const corsAllowHeaders = [
39
+ 'Content-Type',
40
+ 'Authorization',
41
+ 'X-Requested-With',
42
+ 'Access-Control-Request-Private-Network',
43
+ ];
44
+
45
+ function applyPrivateNetworkAccessHeaders(app: OpenAPIHono<BlankEnv>) {
46
+ app.use('*', async (c, next) => {
47
+ c.header('Access-Control-Allow-Private-Network', 'true');
48
+ await next();
49
+ });
50
+ }
51
+
37
52
  function initApp() {
38
53
  const app = new OpenAPIHono<BlankEnv>();
54
+ applyPrivateNetworkAccessHeaders(app);
39
55
 
40
56
  // Enable CORS for localhost and local network access
41
57
  app.use(
@@ -61,7 +77,7 @@ function initApp() {
61
77
  return origin;
62
78
  },
63
79
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
64
- allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
80
+ allowHeaders: corsAllowHeaders,
65
81
  exposeHeaders: ['Content-Length', 'X-Request-Id'],
66
82
  credentials: true,
67
83
  maxAge: 600,
@@ -74,6 +90,7 @@ function initApp() {
74
90
  registerSessionApprovalRoute(app);
75
91
  registerSessionMessagesRoutes(app);
76
92
  registerSessionStreamRoute(app);
93
+ registerClientEventsRoute(app);
77
94
  registerAskRoutes(app);
78
95
  registerConfigRoutes(app);
79
96
  registerFilesRoutes(app);
@@ -112,6 +129,7 @@ export type StandaloneAppConfig = {
112
129
 
113
130
  export function createStandaloneApp(_config?: StandaloneAppConfig) {
114
131
  const honoApp = new OpenAPIHono<BlankEnv>();
132
+ applyPrivateNetworkAccessHeaders(honoApp);
115
133
 
116
134
  honoApp.use(
117
135
  '*',
@@ -136,7 +154,7 @@ export function createStandaloneApp(_config?: StandaloneAppConfig) {
136
154
  return origin;
137
155
  },
138
156
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
139
- allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
157
+ allowHeaders: corsAllowHeaders,
140
158
  exposeHeaders: ['Content-Length', 'X-Request-Id'],
141
159
  credentials: true,
142
160
  maxAge: 600,
@@ -149,6 +167,7 @@ export function createStandaloneApp(_config?: StandaloneAppConfig) {
149
167
  registerSessionApprovalRoute(honoApp);
150
168
  registerSessionMessagesRoutes(honoApp);
151
169
  registerSessionStreamRoute(honoApp);
170
+ registerClientEventsRoute(honoApp);
152
171
  registerAskRoutes(honoApp);
153
172
  registerConfigRoutes(honoApp);
154
173
  registerFilesRoutes(honoApp);
@@ -205,6 +224,7 @@ export type EmbeddedAppConfig = {
205
224
 
206
225
  export function createEmbeddedApp(config: EmbeddedAppConfig = {}) {
207
226
  const honoApp = new OpenAPIHono<BlankEnv>();
227
+ applyPrivateNetworkAccessHeaders(honoApp);
208
228
 
209
229
  // Store injected config in Hono context for routes to access
210
230
  // Config can be empty - routes will fall back to files/env
@@ -247,7 +267,7 @@ export function createEmbeddedApp(config: EmbeddedAppConfig = {}) {
247
267
  return origin;
248
268
  },
249
269
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
250
- allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
270
+ allowHeaders: corsAllowHeaders,
251
271
  exposeHeaders: ['Content-Length', 'X-Request-Id'],
252
272
  credentials: true,
253
273
  maxAge: 600,
@@ -260,6 +280,7 @@ export function createEmbeddedApp(config: EmbeddedAppConfig = {}) {
260
280
  registerSessionApprovalRoute(honoApp);
261
281
  registerSessionMessagesRoutes(honoApp);
262
282
  registerSessionStreamRoute(honoApp);
283
+ registerClientEventsRoute(honoApp);
263
284
  registerAskRoutes(honoApp);
264
285
  registerConfigRoutes(honoApp);
265
286
  registerFilesRoutes(honoApp);
@@ -0,0 +1,139 @@
1
+ import type { Context } from 'hono';
2
+ import type { Hono } from 'hono';
3
+ import { subscribeClientEvents } from '../events/bus.ts';
4
+ import type { ClientEvent } from '../events/types.ts';
5
+ import { openApiRoute } from '../openapi/route.ts';
6
+
7
+ const STREAM_DESCRIPTION =
8
+ 'SSE event stream. Events include notification, session.status, and heartbeat.';
9
+
10
+ function safeStringify(obj: unknown): string {
11
+ return JSON.stringify(obj, (_key, value) =>
12
+ typeof value === 'bigint' ? Number(value) : value,
13
+ );
14
+ }
15
+
16
+ function handleClientEventsStream(c: Context) {
17
+ const headers = new Headers({
18
+ 'Content-Type': 'text/event-stream',
19
+ 'Cache-Control': 'no-cache, no-transform',
20
+ Connection: 'keep-alive',
21
+ });
22
+
23
+ const encoder = new TextEncoder();
24
+
25
+ const stream = new ReadableStream<Uint8Array>({
26
+ start(controller) {
27
+ const write = (evt: ClientEvent) => {
28
+ let line: string;
29
+ try {
30
+ line =
31
+ `event: ${evt.type}\n` +
32
+ `data: ${safeStringify(evt.payload ?? {})}\n\n`;
33
+ } catch {
34
+ line = `event: ${evt.type}\ndata: {}\n\n`;
35
+ }
36
+ controller.enqueue(encoder.encode(line));
37
+ };
38
+
39
+ const unsubscribe = subscribeClientEvents(write);
40
+ controller.enqueue(encoder.encode(': connected client-events\n\n'));
41
+ const hb = setInterval(() => {
42
+ try {
43
+ write({
44
+ type: 'heartbeat',
45
+ payload: { createdAt: new Date().toISOString() },
46
+ });
47
+ } catch {
48
+ clearInterval(hb);
49
+ }
50
+ }, 5000);
51
+
52
+ const signal = c.req.raw?.signal as AbortSignal | undefined;
53
+ signal?.addEventListener('abort', () => {
54
+ clearInterval(hb);
55
+ unsubscribe();
56
+ try {
57
+ controller.close();
58
+ } catch {}
59
+ });
60
+ },
61
+ });
62
+
63
+ return new Response(stream, { headers });
64
+ }
65
+
66
+ export function registerClientEventsRoute(app: Hono) {
67
+ openApiRoute(
68
+ app,
69
+ {
70
+ method: 'get',
71
+ path: '/v1/events/stream',
72
+ operationId: 'subscribeClientEventsStream',
73
+ tags: ['stream'],
74
+ summary: 'Subscribe to global client event stream (SSE)',
75
+ description:
76
+ 'App-level SSE stream for notifications and lightweight cross-session status updates.',
77
+ parameters: [
78
+ {
79
+ in: 'query',
80
+ name: 'project',
81
+ required: false,
82
+ schema: { type: 'string' },
83
+ description:
84
+ 'Project root override (defaults to current working directory).',
85
+ },
86
+ ],
87
+ responses: {
88
+ '200': {
89
+ description: 'text/event-stream',
90
+ content: {
91
+ 'text/event-stream': {
92
+ schema: {
93
+ type: 'string',
94
+ description: STREAM_DESCRIPTION,
95
+ },
96
+ },
97
+ },
98
+ },
99
+ },
100
+ },
101
+ handleClientEventsStream,
102
+ );
103
+ openApiRoute(
104
+ app,
105
+ {
106
+ method: 'post',
107
+ path: '/v1/events/stream',
108
+ operationId: 'subscribeClientEventsStreamPost',
109
+ tags: ['stream'],
110
+ summary: 'Subscribe to global client event stream (SSE) using POST',
111
+ description:
112
+ 'Compatibility alias for app-level SSE over tunnels/proxies that do not support GET streams.',
113
+ parameters: [
114
+ {
115
+ in: 'query',
116
+ name: 'project',
117
+ required: false,
118
+ schema: { type: 'string' },
119
+ description:
120
+ 'Project root override (defaults to current working directory).',
121
+ },
122
+ ],
123
+ responses: {
124
+ '200': {
125
+ description: 'text/event-stream',
126
+ content: {
127
+ 'text/event-stream': {
128
+ schema: {
129
+ type: 'string',
130
+ description: STREAM_DESCRIPTION,
131
+ },
132
+ },
133
+ },
134
+ },
135
+ },
136
+ },
137
+ handleClientEventsStream,
138
+ );
139
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ProviderName } from '../provider/index.ts';
2
- import { publish } from '../../events/bus.ts';
2
+ import { publish, publishClientEvent } from '../../events/bus.ts';
3
3
  import type { ToolApprovalMode } from '../tools/approval.ts';
4
4
  import type { ReasoningLevel } from '@ottocode/sdk';
5
5
 
@@ -234,6 +234,17 @@ export function setCurrentMessage(sessionId: string, messageId: string | null) {
234
234
  if (state) {
235
235
  state.currentMessageId = messageId;
236
236
  publishQueueState(sessionId);
237
+ if (messageId) {
238
+ publishClientEvent({
239
+ type: 'session.status',
240
+ payload: {
241
+ sessionId,
242
+ status: 'running',
243
+ messageId,
244
+ createdAt: new Date().toISOString(),
245
+ },
246
+ });
247
+ }
237
248
  }
238
249
  }
239
250
 
@@ -243,6 +254,15 @@ export function dequeueJob(sessionId: string): RunOpts | undefined {
243
254
  if (job && state) {
244
255
  state.currentMessageId = job.assistantMessageId;
245
256
  publishQueueState(sessionId);
257
+ publishClientEvent({
258
+ type: 'session.status',
259
+ payload: {
260
+ sessionId,
261
+ status: 'running',
262
+ messageId: job.assistantMessageId,
263
+ createdAt: new Date().toISOString(),
264
+ },
265
+ });
246
266
  }
247
267
  return job;
248
268
  }
@@ -2,7 +2,7 @@ import type { getDb } from '@ottocode/database';
2
2
  import { messages, messageParts } from '@ottocode/database/schema';
3
3
  import { eq } from 'drizzle-orm';
4
4
  import { APICallError } from 'ai';
5
- import { publish } from '../../events/bus.ts';
5
+ import { publish, publishClientEvent } from '../../events/bus.ts';
6
6
  import { toErrorPayload } from '../errors/handling.ts';
7
7
  import type { RunOpts } from '../session/queue.ts';
8
8
  import type { ToolAdapterContext } from '../../tools/adapter.ts';
@@ -349,5 +349,28 @@ export function createErrorHandler(
349
349
  autoCompacted: isPromptTooLong && !opts.isCompactCommand,
350
350
  },
351
351
  });
352
+
353
+ const createdAt = new Date().toISOString();
354
+ publishClientEvent({
355
+ type: 'session.status',
356
+ payload: {
357
+ sessionId: opts.sessionId,
358
+ status: 'failed',
359
+ messageId: opts.assistantMessageId,
360
+ createdAt,
361
+ },
362
+ });
363
+ publishClientEvent({
364
+ type: 'notification',
365
+ payload: {
366
+ id: crypto.randomUUID(),
367
+ level: 'error',
368
+ title: 'Session failed',
369
+ body: displayMessage,
370
+ source: 'session',
371
+ sessionId: opts.sessionId,
372
+ createdAt,
373
+ },
374
+ });
352
375
  };
353
376
  }
@@ -1,7 +1,7 @@
1
1
  import type { getDb } from '@ottocode/database';
2
2
  import { messages, messageParts } from '@ottocode/database/schema';
3
3
  import { eq } from 'drizzle-orm';
4
- import { publish } from '../../events/bus.ts';
4
+ import { publish, publishClientEvent } from '../../events/bus.ts';
5
5
  import { estimateModelCostUsd } from '@ottocode/sdk';
6
6
  import type { RunOpts } from '../session/queue.ts';
7
7
  import { markSessionCompacted } from '../message/compaction.ts';
@@ -84,5 +84,32 @@ export function createFinishHandler(
84
84
  finishReason: fin.finishReason,
85
85
  },
86
86
  });
87
+
88
+ const createdAt = new Date().toISOString();
89
+ const status = fin.finishReason === 'error' ? 'failed' : 'completed';
90
+ publishClientEvent({
91
+ type: 'session.status',
92
+ payload: {
93
+ sessionId: opts.sessionId,
94
+ status,
95
+ messageId: opts.assistantMessageId,
96
+ createdAt,
97
+ },
98
+ });
99
+ publishClientEvent({
100
+ type: 'notification',
101
+ payload: {
102
+ id: crypto.randomUUID(),
103
+ level: status === 'failed' ? 'error' : 'success',
104
+ title: status === 'failed' ? 'Session failed' : 'Session completed',
105
+ body:
106
+ status === 'failed'
107
+ ? 'An assistant run ended with an error.'
108
+ : 'An assistant run finished successfully.',
109
+ source: 'session',
110
+ sessionId: opts.sessionId,
111
+ createdAt,
112
+ },
113
+ });
87
114
  };
88
115
  }