@ottocode/server 0.1.226 → 0.1.227

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.226",
3
+ "version": "0.1.227",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -49,8 +49,8 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@ottocode/sdk": "0.1.226",
53
- "@ottocode/database": "0.1.226",
52
+ "@ottocode/sdk": "0.1.227",
53
+ "@ottocode/database": "0.1.227",
54
54
  "drizzle-orm": "^0.44.5",
55
55
  "hono": "^4.9.9",
56
56
  "zod": "^4.3.6"
@@ -1,5 +1,6 @@
1
1
  export type OttoEventType =
2
2
  | 'tool.approval.required'
3
+ | 'tool.approval.updated'
3
4
  | 'tool.approval.resolved'
4
5
  | 'setu.payment.required'
5
6
  | 'setu.payment.signing'
@@ -11,6 +12,7 @@ export type OttoEventType =
11
12
  | 'setu.fiat.checkout_created'
12
13
  | 'setu.balance.updated'
13
14
  | 'session.created'
15
+ | 'session.deleted'
14
16
  | 'session.updated'
15
17
  | 'message.created'
16
18
  | 'message.updated'
@@ -0,0 +1,7 @@
1
+ import type { EmbeddedAppConfig } from './index.ts';
2
+
3
+ declare module 'hono' {
4
+ interface ContextVariableMap {
5
+ embeddedConfig: EmbeddedAppConfig | undefined;
6
+ }
7
+ }
package/src/index.ts CHANGED
@@ -191,6 +191,7 @@ export type EmbeddedAppConfig = {
191
191
  provider?: ProviderId;
192
192
  model?: string;
193
193
  agent?: string;
194
+ toolApproval?: 'auto' | 'dangerous' | 'all';
194
195
  };
195
196
  /** Additional CORS origins for proxies/Tailscale (e.g., ['https://myapp.ts.net', 'https://example.com']) */
196
197
  corsOrigins?: string[];
@@ -202,7 +203,14 @@ export function createEmbeddedApp(config: EmbeddedAppConfig = {}) {
202
203
  // Store injected config in Hono context for routes to access
203
204
  // Config can be empty - routes will fall back to files/env
204
205
  honoApp.use('*', async (c, next) => {
205
- c.set('embeddedConfig', config);
206
+ (
207
+ c as unknown as {
208
+ set: (
209
+ key: 'embeddedConfig',
210
+ value: EmbeddedAppConfig | undefined,
211
+ ) => void;
212
+ }
213
+ ).set('embeddedConfig', config);
206
214
  await next();
207
215
  });
208
216
 
package/src/routes/ask.ts CHANGED
@@ -21,9 +21,11 @@ export function registerAskRoutes(app: Hono) {
21
21
  return c.json({ error: 'Prompt is required.' }, 400);
22
22
  }
23
23
 
24
- const embeddedConfig = c.get('embeddedConfig') as
25
- | EmbeddedAppConfig
26
- | undefined;
24
+ const embeddedConfig = (
25
+ c as unknown as {
26
+ get: (key: 'embeddedConfig') => EmbeddedAppConfig | undefined;
27
+ }
28
+ ).get('embeddedConfig');
27
29
 
28
30
  // Hybrid fallback: Use embedded config if provided, otherwise fall back to files/env
29
31
  let injectableConfig: InjectableConfig | undefined;
@@ -8,9 +8,11 @@ import { discoverAllAgents, getDefault } from './utils.ts';
8
8
  export function registerAgentsRoute(app: Hono) {
9
9
  app.get('/v1/config/agents', async (c) => {
10
10
  try {
11
- const embeddedConfig = c.get('embeddedConfig') as
12
- | EmbeddedAppConfig
13
- | undefined;
11
+ const embeddedConfig = (
12
+ c as unknown as {
13
+ get: (key: 'embeddedConfig') => EmbeddedAppConfig | undefined;
14
+ }
15
+ ).get('embeddedConfig');
14
16
 
15
17
  if (embeddedConfig) {
16
18
  const agents = embeddedConfig.agents
@@ -1,5 +1,5 @@
1
1
  import type { Hono } from 'hono';
2
- import { setConfig, loadConfig } from '@ottocode/sdk';
2
+ import { setConfig, loadConfig, type ProviderId } from '@ottocode/sdk';
3
3
  import { logger } from '@ottocode/sdk';
4
4
  import { serializeError } from '../../runtime/errors/api-error.ts';
5
5
 
@@ -21,7 +21,7 @@ export function registerDefaultsRoute(app: Hono) {
21
21
  const scope = body.scope || 'global';
22
22
  const updates: Partial<{
23
23
  agent: string;
24
- provider: string;
24
+ provider: ProviderId;
25
25
  model: string;
26
26
  toolApproval: 'auto' | 'dangerous' | 'all';
27
27
  guidedMode: boolean;
@@ -30,7 +30,7 @@ export function registerDefaultsRoute(app: Hono) {
30
30
  }> = {};
31
31
 
32
32
  if (body.agent) updates.agent = body.agent;
33
- if (body.provider) updates.provider = body.provider;
33
+ if (body.provider) updates.provider = body.provider as ProviderId;
34
34
  if (body.model) updates.model = body.model;
35
35
  if (body.toolApproval) updates.toolApproval = body.toolApproval;
36
36
  if (body.guidedMode !== undefined) updates.guidedMode = body.guidedMode;
@@ -13,9 +13,11 @@ export function registerMainConfigRoute(app: Hono) {
13
13
  app.get('/v1/config', async (c) => {
14
14
  try {
15
15
  const projectRoot = c.req.query('project') || process.cwd();
16
- const embeddedConfig = c.get('embeddedConfig') as
17
- | EmbeddedAppConfig
18
- | undefined;
16
+ const embeddedConfig = (
17
+ c as unknown as {
18
+ get: (key: 'embeddedConfig') => EmbeddedAppConfig | undefined;
19
+ }
20
+ ).get('embeddedConfig');
19
21
 
20
22
  const cfg = await loadConfig(projectRoot);
21
23
 
@@ -82,9 +82,11 @@ async function getAuthorizedCopilotModels(
82
82
  export function registerModelsRoutes(app: Hono) {
83
83
  app.get('/v1/config/providers/:provider/models', async (c) => {
84
84
  try {
85
- const embeddedConfig = c.get('embeddedConfig') as
86
- | EmbeddedAppConfig
87
- | undefined;
85
+ const embeddedConfig = (
86
+ c as unknown as {
87
+ get: (key: 'embeddedConfig') => EmbeddedAppConfig | undefined;
88
+ }
89
+ ).get('embeddedConfig');
88
90
  const provider = c.req.param('provider') as ProviderId;
89
91
 
90
92
  const projectRoot = c.req.query('project') || process.cwd();
@@ -152,9 +154,11 @@ export function registerModelsRoutes(app: Hono) {
152
154
 
153
155
  app.get('/v1/config/models', async (c) => {
154
156
  try {
155
- const embeddedConfig = c.get('embeddedConfig') as
156
- | EmbeddedAppConfig
157
- | undefined;
157
+ const embeddedConfig = (
158
+ c as unknown as {
159
+ get: (key: 'embeddedConfig') => EmbeddedAppConfig | undefined;
160
+ }
161
+ ).get('embeddedConfig');
158
162
 
159
163
  const projectRoot = c.req.query('project') || process.cwd();
160
164
  const cfg = await loadConfig(projectRoot);
@@ -9,14 +9,18 @@ import { getAuthorizedProviders, getDefault } from './utils.ts';
9
9
  export function registerProvidersRoute(app: Hono) {
10
10
  app.get('/v1/config/providers', async (c) => {
11
11
  try {
12
- const embeddedConfig = c.get('embeddedConfig') as
13
- | EmbeddedAppConfig
14
- | undefined;
12
+ const embeddedConfig = (
13
+ c as unknown as {
14
+ get: (key: 'embeddedConfig') => EmbeddedAppConfig | undefined;
15
+ }
16
+ ).get('embeddedConfig');
15
17
 
16
18
  if (embeddedConfig) {
17
19
  const providers = embeddedConfig.auth
18
20
  ? (Object.keys(embeddedConfig.auth) as ProviderId[])
19
- : [embeddedConfig.provider];
21
+ : embeddedConfig.provider
22
+ ? [embeddedConfig.provider]
23
+ : [];
20
24
 
21
25
  return c.json({
22
26
  providers,
@@ -63,7 +63,8 @@ export async function getAuthTypeForProvider(
63
63
  projectRoot: string,
64
64
  ): Promise<'api' | 'oauth' | 'wallet' | undefined> {
65
65
  if (embeddedConfig?.auth?.[provider]) {
66
- return embeddedConfig.auth[provider].type as 'api' | 'oauth' | 'wallet';
66
+ const embeddedAuth = embeddedConfig.auth[provider];
67
+ return 'type' in embeddedAuth ? embeddedAuth.type : 'api';
67
68
  }
68
69
  const auth = await getAuth(provider, projectRoot);
69
70
  return auth?.type as 'api' | 'oauth' | 'wallet' | undefined;
@@ -83,7 +84,9 @@ export async function discoverAllAgents(
83
84
  }
84
85
  }
85
86
  } catch (err) {
86
- logger.debug('Failed to load agents.json', err);
87
+ logger.debug('Failed to load agents.json', {
88
+ error: err instanceof Error ? err.message : String(err),
89
+ });
87
90
  }
88
91
 
89
92
  try {
@@ -98,7 +101,9 @@ export async function discoverAllAgents(
98
101
  }
99
102
  }
100
103
  } catch (err) {
101
- logger.debug('Failed to read local agents directory', err);
104
+ logger.debug('Failed to read local agents directory', {
105
+ error: err instanceof Error ? err.message : String(err),
106
+ });
102
107
  }
103
108
 
104
109
  try {
@@ -113,7 +118,9 @@ export async function discoverAllAgents(
113
118
  }
114
119
  }
115
120
  } catch (err) {
116
- logger.debug('Failed to read global agents directory', err);
121
+ logger.debug('Failed to read global agents directory', {
122
+ error: err instanceof Error ? err.message : String(err),
123
+ });
117
124
  }
118
125
 
119
126
  return Array.from(agentSet).sort();
@@ -87,12 +87,14 @@ export function registerTerminalsRoutes(
87
87
  return c.json({ error: 'Terminal not found' }, 404);
88
88
  }
89
89
 
90
+ const activeTerminal = terminal;
91
+
90
92
  return streamSSE(c, async (stream) => {
91
93
  logger.debug('SSE stream started for terminal', { id });
92
94
  // Send historical buffer first (unless skipHistory is set)
93
95
  const skipHistory = c.req.query('skipHistory') === 'true';
94
96
  if (!skipHistory) {
95
- const history = terminal.read();
97
+ const history = activeTerminal.read();
96
98
  logger.debug('SSE sending terminal history', {
97
99
  id,
98
100
  lines: history.length,
@@ -120,8 +122,8 @@ export function registerTerminalsRoutes(
120
122
  let finished = false;
121
123
 
122
124
  function cleanup() {
123
- terminal.removeDataListener(onData);
124
- terminal.removeExitListener(onExit);
125
+ activeTerminal.removeDataListener(onData);
126
+ activeTerminal.removeExitListener(onExit);
125
127
  c.req.raw.signal.removeEventListener('abort', onAbort);
126
128
  }
127
129
 
@@ -145,7 +147,7 @@ export function registerTerminalsRoutes(
145
147
 
146
148
  function onAbort() {
147
149
  logger.debug('SSE client disconnected from terminal', {
148
- id: terminal.id,
150
+ id: activeTerminal.id,
149
151
  });
150
152
  stream.close();
151
153
  finish();
@@ -59,7 +59,7 @@ export function registerTunnelRoutes(app: Hono) {
59
59
 
60
60
  const url = await activeTunnel.start(port, (msg) => {
61
61
  progressMessage = msg;
62
- logger.debug('Tunnel progress:', msg);
62
+ logger.debug('Tunnel progress', { message: msg });
63
63
  });
64
64
 
65
65
  tunnelUrl = url;
@@ -183,11 +183,15 @@ async function runAssistant(opts: RunOpts) {
183
183
  let _toolActivityObserved = false;
184
184
  let _trailingAssistantTextAfterTool = false;
185
185
  let _abortedByUser = false;
186
+ let titleGenerationTriggered = false;
186
187
  const unsubscribeFinish = subscribe(opts.sessionId, (evt) => {
187
188
  if (evt.type === 'tool.call' || evt.type === 'tool.result') {
188
189
  _toolActivityObserved = true;
189
190
  _trailingAssistantTextAfterTool = false;
190
191
  }
192
+ if (evt.type === 'tool.call') {
193
+ triggerTitleGenerationWhenReady();
194
+ }
191
195
  if (evt.type !== 'tool.result') return;
192
196
  try {
193
197
  const name = (evt.payload as { name?: string } | undefined)?.name;
@@ -222,6 +226,22 @@ async function runAssistant(opts: RunOpts) {
222
226
  stepIndex += 1;
223
227
  return stepIndex;
224
228
  };
229
+ const triggerTitleGenerationWhenReady = () => {
230
+ if (titleGenerationTriggered) {
231
+ return;
232
+ }
233
+
234
+ titleGenerationTriggered = true;
235
+ if (!isFirstMessage) {
236
+ return;
237
+ }
238
+
239
+ void triggerDeferredTitleGeneration({
240
+ cfg,
241
+ db,
242
+ sessionId: opts.sessionId,
243
+ });
244
+ };
225
245
 
226
246
  const reasoningStates = new Map<string, ReasoningState>();
227
247
 
@@ -233,6 +253,7 @@ async function runAssistant(opts: RunOpts) {
233
253
  getCurrentPartId,
234
254
  updateCurrentPartId,
235
255
  updateAccumulated,
256
+ triggerTitleGenerationWhenReady,
236
257
  sharedCtx,
237
258
  updateSessionTokensIncremental,
238
259
  updateMessageTokensIncremental,
@@ -315,13 +336,6 @@ async function runAssistant(opts: RunOpts) {
315
336
  if (!firstDeltaSeen) {
316
337
  firstDeltaSeen = true;
317
338
  streamStartTimer.end();
318
- if (isFirstMessage) {
319
- void triggerDeferredTitleGeneration({
320
- cfg,
321
- db,
322
- sessionId: opts.sessionId,
323
- });
324
- }
325
339
  }
326
340
 
327
341
  if (!currentPartId) {
@@ -127,7 +127,12 @@ async function processAskRequest(
127
127
  google: { enabled: true },
128
128
  openrouter: { enabled: true },
129
129
  opencode: { enabled: true },
130
+ copilot: { enabled: true },
130
131
  setu: { enabled: true },
132
+ zai: { enabled: true },
133
+ 'zai-coding': { enabled: true },
134
+ moonshot: { enabled: true },
135
+ minimax: { enabled: true },
131
136
  },
132
137
  paths: {
133
138
  dataDir: `${projectRoot}/.otto`,
@@ -5,6 +5,7 @@
5
5
  * across all API endpoints.
6
6
  */
7
7
 
8
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
8
9
  import { isDebugEnabled } from '../debug/state.ts';
9
10
  import { toErrorPayload } from './handling.ts';
10
11
 
@@ -16,7 +17,7 @@ export type APIErrorResponse = {
16
17
  message: string;
17
18
  type: string;
18
19
  code?: string;
19
- status?: number;
20
+ status?: ContentfulStatusCode;
20
21
  details?: Record<string, unknown>;
21
22
  stack?: string;
22
23
  };
@@ -27,29 +28,33 @@ export type APIErrorResponse = {
27
28
  */
28
29
  export class APIError extends Error {
29
30
  public readonly code?: string;
30
- public readonly status: number;
31
+ public readonly status: ContentfulStatusCode;
31
32
  public readonly type: string;
32
33
  public readonly details?: Record<string, unknown>;
33
34
 
34
35
  constructor(
35
36
  message: string,
36
- options?: {
37
- code?: string;
38
- status?: number;
39
- type?: string;
40
- details?: Record<string, unknown>;
41
- cause?: unknown;
42
- },
37
+ options?:
38
+ | ContentfulStatusCode
39
+ | {
40
+ code?: string;
41
+ status?: ContentfulStatusCode;
42
+ type?: string;
43
+ details?: Record<string, unknown>;
44
+ cause?: unknown;
45
+ },
43
46
  ) {
44
47
  super(message);
45
48
  this.name = 'APIError';
46
- this.code = options?.code;
47
- this.status = options?.status ?? 500;
48
- this.type = options?.type ?? 'api_error';
49
- this.details = options?.details;
50
-
51
- if (options?.cause) {
52
- this.cause = options.cause;
49
+ const normalizedOptions =
50
+ typeof options === 'number' ? { status: options } : options;
51
+ this.code = normalizedOptions?.code;
52
+ this.status = normalizedOptions?.status ?? 500;
53
+ this.type = normalizedOptions?.type ?? 'api_error';
54
+ this.details = normalizedOptions?.details;
55
+
56
+ if (normalizedOptions?.cause) {
57
+ this.cause = normalizedOptions.cause;
53
58
  }
54
59
 
55
60
  // Maintain proper stack trace
@@ -72,7 +77,7 @@ export function serializeError(err: unknown): APIErrorResponse {
72
77
  // Determine HTTP status code
73
78
  // Default to 400 for generic errors (client errors)
74
79
  // Only use 500 if explicitly set or for APIError instances without a status
75
- let status = 400;
80
+ let status: ContentfulStatusCode = 400;
76
81
 
77
82
  // Handle APIError instances first
78
83
  if (err instanceof APIError) {
@@ -80,15 +85,16 @@ export function serializeError(err: unknown): APIErrorResponse {
80
85
  } else if (err && typeof err === 'object') {
81
86
  const errObj = err as Record<string, unknown>;
82
87
  if (typeof errObj.status === 'number') {
83
- status = errObj.status;
88
+ status = errObj.status as ContentfulStatusCode;
84
89
  } else if (typeof errObj.statusCode === 'number') {
85
- status = errObj.statusCode;
90
+ status = errObj.statusCode as ContentfulStatusCode;
86
91
  } else if (
87
92
  errObj.details &&
88
93
  typeof errObj.details === 'object' &&
89
94
  typeof (errObj.details as Record<string, unknown>).statusCode === 'number'
90
95
  ) {
91
- status = (errObj.details as Record<string, unknown>).statusCode as number;
96
+ status = (errObj.details as Record<string, unknown>)
97
+ .statusCode as ContentfulStatusCode;
92
98
  }
93
99
  }
94
100
 
@@ -130,7 +136,9 @@ export function serializeError(err: unknown): APIErrorResponse {
130
136
  * @param err - The error to convert
131
137
  * @returns Tuple of [APIErrorResponse, HTTP status code]
132
138
  */
133
- export function createErrorResponse(err: unknown): [APIErrorResponse, number] {
139
+ export function createErrorResponse(
140
+ err: unknown,
141
+ ): [APIErrorResponse, ContentfulStatusCode] {
134
142
  const response = serializeError(err);
135
143
  return [response, response.error.status ?? 500];
136
144
  }
@@ -284,7 +284,7 @@ async function generateSessionTitle(args: {
284
284
  return;
285
285
  }
286
286
 
287
- const provider = sess.provider ?? cfg.defaults.provider;
287
+ const provider = (sess.provider ?? cfg.defaults.provider) as ProviderId;
288
288
  const modelName = sess.model ?? cfg.defaults.model;
289
289
 
290
290
  debugLog('[TITLE_GEN] Generating title for session');
@@ -365,7 +365,7 @@ Output ONLY the title, nothing else.`;
365
365
 
366
366
  await db
367
367
  .update(sessions)
368
- .set({ title: sanitized, updatedAt: Date.now() })
368
+ .set({ title: sanitized, lastActiveAt: Date.now() })
369
369
  .where(eq(sessions.id, sessionId));
370
370
 
371
371
  debugLog(`[TITLE_GEN] Setting final title: "${sanitized}"`);
@@ -160,7 +160,14 @@ export async function createBranch({
160
160
  }
161
161
 
162
162
  const result: SessionRow = {
163
- ...newSession,
163
+ id: newSession.id,
164
+ title: newSession.title ?? null,
165
+ agent: newSession.agent,
166
+ provider: newSession.provider,
167
+ model: newSession.model,
168
+ projectPath: newSession.projectPath,
169
+ createdAt: newSession.createdAt,
170
+ lastActiveAt: newSession.lastActiveAt ?? null,
164
171
  totalInputTokens: null,
165
172
  totalOutputTokens: null,
166
173
  totalCachedTokens: null,
@@ -171,6 +178,9 @@ export async function createBranch({
171
178
  currentContextTokens: null,
172
179
  contextSummary: null,
173
180
  lastCompactedAt: null,
181
+ parentSessionId: newSession.parentSessionId ?? null,
182
+ branchPointMessageId: newSession.branchPointMessageId ?? null,
183
+ sessionType: newSession.sessionType ?? null,
174
184
  };
175
185
 
176
186
  publish({
@@ -39,7 +39,7 @@ export async function createSession({
39
39
  await ensureProviderEnv(cfg, provider);
40
40
  const id = crypto.randomUUID();
41
41
  const now = Date.now();
42
- const row = {
42
+ const row: SessionRow = {
43
43
  id,
44
44
  title: title ?? null,
45
45
  agent,
@@ -56,6 +56,11 @@ export async function createSession({
56
56
  totalToolTimeMs: null,
57
57
  toolCountsJson: null,
58
58
  currentContextTokens: null,
59
+ contextSummary: null,
60
+ lastCompactedAt: null,
61
+ parentSessionId: null,
62
+ branchPointMessageId: null,
63
+ sessionType: 'main',
59
64
  };
60
65
  await db.insert(sessions).values(row);
61
66
  publish({ type: 'session.created', sessionId: id, payload: row });
@@ -16,6 +16,7 @@ export function createStepFinishHandler(
16
16
  getCurrentPartId: () => string | null,
17
17
  updateCurrentPartId: (id: string | null) => void,
18
18
  updateAccumulated: (text: string) => void,
19
+ triggerTitleGenerationWhenReady: () => void,
19
20
  sharedCtx: ToolAdapterContext,
20
21
  updateSessionTokensIncrementalFn: (
21
22
  usage: UsageData,
@@ -31,6 +32,8 @@ export function createStepFinishHandler(
31
32
  ) => Promise<void>,
32
33
  ) {
33
34
  return async (step: StepFinishEvent) => {
35
+ triggerTitleGenerationWhenReady();
36
+
34
37
  const finishedAt = Date.now();
35
38
  const currentPartId = getCurrentPartId();
36
39
  const stepIndex = getStepIndex();
@@ -130,12 +130,13 @@ export function adaptTools(
130
130
  }: {
131
131
  callId?: string;
132
132
  startTs?: number;
133
- stepIndexForEvent: number;
133
+ stepIndexForEvent?: number;
134
134
  args?: unknown;
135
135
  },
136
136
  ) => {
137
137
  const resultPartId = crypto.randomUUID();
138
138
  const endTs = Date.now();
139
+ const effectiveStepIndex = stepIndexForEvent ?? ctx.stepIndex;
139
140
  const dur =
140
141
  typeof startTs === 'number' ? Math.max(0, endTs - startTs) : null;
141
142
 
@@ -160,7 +161,7 @@ export function adaptTools(
160
161
  id: resultPartId,
161
162
  messageId: ctx.messageId,
162
163
  index,
163
- stepIndex: stepIndexForEvent,
164
+ stepIndex: effectiveStepIndex,
164
165
  type: 'tool_result',
165
166
  content: JSON.stringify(contentObj),
166
167
  agent: ctx.agent,
@@ -176,7 +177,7 @@ export function adaptTools(
176
177
  publish({
177
178
  type: 'tool.result',
178
179
  sessionId: ctx.sessionId,
179
- payload: { ...contentObj, stepIndex: stepIndexForEvent },
180
+ payload: { ...contentObj, stepIndex: effectiveStepIndex },
180
181
  });
181
182
  };
182
183