@ottocode/server 0.1.179 → 0.1.181

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.179",
3
+ "version": "0.1.181",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -29,8 +29,8 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@ottocode/sdk": "0.1.179",
33
- "@ottocode/database": "0.1.179",
32
+ "@ottocode/sdk": "0.1.181",
33
+ "@ottocode/database": "0.1.181",
34
34
  "drizzle-orm": "^0.44.5",
35
35
  "hono": "^4.9.9",
36
36
  "zod": "^4.1.8"
package/src/events/bus.ts CHANGED
@@ -25,7 +25,12 @@ export function publish(event: OttoEvent) {
25
25
  for (const sub of subs) {
26
26
  try {
27
27
  sub(sanitizedEvent);
28
- } catch {}
28
+ } catch (err) {
29
+ console.error(
30
+ `[bus] Subscriber threw on event ${event.type}:`,
31
+ err instanceof Error ? err.message : String(err),
32
+ );
33
+ }
29
34
  }
30
35
  }
31
36
 
@@ -20,6 +20,11 @@ export function registerSessionsRoutes(app: Hono) {
20
20
  // List sessions
21
21
  app.get('/v1/sessions', async (c) => {
22
22
  const projectRoot = c.req.query('project') || process.cwd();
23
+ const limit = Math.min(
24
+ Math.max(parseInt(c.req.query('limit') || '50', 10) || 50, 1),
25
+ 200,
26
+ );
27
+ const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
23
28
  const cfg = await loadConfig(projectRoot);
24
29
  const db = await getDb(cfg.projectRoot);
25
30
  // Only return sessions for this project, excluding research sessions
@@ -32,8 +37,12 @@ export function registerSessionsRoutes(app: Hono) {
32
37
  ne(sessions.sessionType, 'research'),
33
38
  ),
34
39
  )
35
- .orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt));
36
- const normalized = rows.map((r) => {
40
+ .orderBy(desc(sessions.lastActiveAt), desc(sessions.createdAt))
41
+ .limit(limit + 1)
42
+ .offset(offset);
43
+ const hasMore = rows.length > limit;
44
+ const page = hasMore ? rows.slice(0, limit) : rows;
45
+ const normalized = page.map((r) => {
37
46
  let counts: Record<string, unknown> | undefined;
38
47
  if (r.toolCountsJson) {
39
48
  try {
@@ -46,7 +55,11 @@ export function registerSessionsRoutes(app: Hono) {
46
55
  const { toolCountsJson: _toolCountsJson, ...rest } = r;
47
56
  return counts ? { ...rest, toolCounts: counts } : rest;
48
57
  });
49
- return c.json(normalized);
58
+ return c.json({
59
+ items: normalized,
60
+ hasMore,
61
+ nextOffset: hasMore ? offset + limit : null,
62
+ });
50
63
  });
51
64
 
52
65
  // Create session
@@ -1,4 +1,6 @@
1
1
  import { loadConfig, getUnderlyingProviderKey } from '@ottocode/sdk';
2
+ import { wrapLanguageModel } from 'ai';
3
+ import { devToolsMiddleware } from '@ai-sdk/devtools';
2
4
  import { getDb } from '@ottocode/database';
3
5
  import { sessions } from '@ottocode/database/schema';
4
6
  import { eq } from 'drizzle-orm';
@@ -8,7 +10,7 @@ import { composeSystemPrompt } from '../prompt/builder.ts';
8
10
  import { discoverProjectTools } from '@ottocode/sdk';
9
11
  import { adaptTools } from '../../tools/adapter.ts';
10
12
  import { buildDatabaseTools } from '../../tools/database/index.ts';
11
- import { debugLog, time } from '../debug/index.ts';
13
+ import { debugLog, time, isDebugEnabled } from '../debug/index.ts';
12
14
  import { buildHistoryMessages } from '../message/history-builder.ts';
13
15
  import { getMaxOutputTokens } from '../utils/token.ts';
14
16
  import { setupToolContext } from '../tools/setup.ts';
@@ -25,7 +27,9 @@ export interface SetupResult {
25
27
  system: string;
26
28
  systemComponents: string[];
27
29
  additionalSystemMessages: Array<{ role: 'system' | 'user'; content: string }>;
28
- model: Awaited<ReturnType<typeof resolveModel>>;
30
+ model:
31
+ | Awaited<ReturnType<typeof resolveModel>>
32
+ | ReturnType<typeof wrapLanguageModel>;
29
33
  maxOutputTokens: number | undefined;
30
34
  effectiveMaxOutputTokens: number | undefined;
31
35
  toolset: ReturnType<typeof adaptTools>;
@@ -34,6 +38,7 @@ export interface SetupResult {
34
38
  firstToolSeen: () => boolean;
35
39
  providerOptions: Record<string, unknown>;
36
40
  needsSpoof: boolean;
41
+ isOpenAIOAuth: boolean;
37
42
  }
38
43
 
39
44
  const THINKING_BUDGET = 16000;
@@ -163,6 +168,13 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
163
168
  sessionId: opts.sessionId,
164
169
  messageId: opts.assistantMessageId,
165
170
  });
171
+ const wrappedModel = isDebugEnabled()
172
+ ? // biome-ignore lint/suspicious/noExplicitAny: OpenRouter provider uses v2 spec
173
+ wrapLanguageModel({
174
+ model: model as any,
175
+ middleware: devToolsMiddleware(),
176
+ })
177
+ : model;
166
178
  debugLog(
167
179
  `[RUNNER] Model created: ${JSON.stringify({ id: model.modelId, provider: model.provider })}`,
168
180
  );
@@ -223,7 +235,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
223
235
  system,
224
236
  systemComponents,
225
237
  additionalSystemMessages,
226
- model,
238
+ model: wrappedModel,
227
239
  maxOutputTokens,
228
240
  effectiveMaxOutputTokens,
229
241
  toolset,
@@ -232,6 +244,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
232
244
  firstToolSeen,
233
245
  providerOptions,
234
246
  needsSpoof: oauth.needsSpoof,
247
+ isOpenAIOAuth: oauth.isOpenAIOAuth,
235
248
  };
236
249
  }
237
250
 
@@ -51,7 +51,9 @@ export async function runSessionLoop(sessionId: string) {
51
51
  try {
52
52
  await runAssistant(job);
53
53
  } catch (_err) {
54
- // Swallow to keep the loop alive; event published by runner
54
+ debugLog(
55
+ `[RUNNER] runAssistant threw (swallowed to keep loop alive): ${_err instanceof Error ? _err.message : String(_err)}`,
56
+ );
55
57
  }
56
58
  }
57
59
 
@@ -80,6 +82,7 @@ async function runAssistant(opts: RunOpts) {
80
82
  firstToolTimer,
81
83
  firstToolSeen,
82
84
  providerOptions,
85
+ isOpenAIOAuth,
83
86
  } = setup;
84
87
 
85
88
  const isFirstMessage = !history.some((m) => m.role === 'assistant');
@@ -91,7 +94,7 @@ async function runAssistant(opts: RunOpts) {
91
94
 
92
95
  if (!isFirstMessage) {
93
96
  messagesWithSystemInstructions.push({
94
- role: 'user',
97
+ role: isOpenAIOAuth ? 'system' : 'user',
95
98
  content:
96
99
  'SYSTEM REMINDER: You are continuing an existing session. When you have completed the task, you MUST stream a text summary of what you did to the user, and THEN call the `finish` tool. Do not call `finish` without a summary.',
97
100
  });
@@ -107,7 +110,11 @@ async function runAssistant(opts: RunOpts) {
107
110
  try {
108
111
  const name = (evt.payload as { name?: string } | undefined)?.name;
109
112
  if (name === 'finish') _finishObserved = true;
110
- } catch {}
113
+ } catch (err) {
114
+ debugLog(
115
+ `[RUNNER] finish observer error: ${err instanceof Error ? err.message : String(err)}`,
116
+ );
117
+ }
111
118
  });
112
119
 
113
120
  const streamStartTimer = time('runner:first-delta');
@@ -289,6 +296,12 @@ async function runAssistant(opts: RunOpts) {
289
296
  debugLog(
290
297
  `[RUNNER] Stream finished. finishSeen=${_finishObserved}, firstToolSeen=${fs}`,
291
298
  );
299
+
300
+ if (!_finishObserved && fs) {
301
+ debugLog(
302
+ `[RUNNER] WARNING: Stream ended without finish tool being called. Model was mid-execution (tools were used). This is likely an unclean stream termination from the provider.`,
303
+ );
304
+ }
292
305
  } catch (err) {
293
306
  unsubscribeFinish();
294
307
  const payload = toErrorPayload(err);
@@ -334,7 +347,11 @@ async function runAssistant(opts: RunOpts) {
334
347
 
335
348
  try {
336
349
  await completeAssistantMessage({}, opts, db);
337
- } catch {}
350
+ } catch (err2) {
351
+ debugLog(
352
+ `[RUNNER] completeAssistantMessage failed after prune: ${err2 instanceof Error ? err2.message : String(err2)}`,
353
+ );
354
+ }
338
355
  return;
339
356
  } catch (pruneErr) {
340
357
  debugLog(
@@ -364,7 +381,11 @@ async function runAssistant(opts: RunOpts) {
364
381
  db,
365
382
  );
366
383
  await completeAssistantMessage({}, opts, db);
367
- } catch {}
384
+ } catch (err2) {
385
+ debugLog(
386
+ `[RUNNER] Failed to complete message after error: ${err2 instanceof Error ? err2.message : String(err2)}`,
387
+ );
388
+ }
368
389
  throw err;
369
390
  } finally {
370
391
  debugLog(
@@ -77,6 +77,9 @@ function describeToolResult(info: ToolResultInfo): TargetDescriptor | null {
77
77
  return describeWrite(info);
78
78
  case 'apply_patch':
79
79
  return describePatch(info);
80
+ case 'edit':
81
+ case 'multiedit':
82
+ return describeEdit(info);
80
83
  default:
81
84
  return null;
82
85
  }
@@ -202,3 +205,14 @@ function getNumber(value: unknown): number | undefined {
202
205
  function normalizePath(path: string): string {
203
206
  return path.replace(/\\/g, '/');
204
207
  }
208
+
209
+ function describeEdit(info: ToolResultInfo): TargetDescriptor | null {
210
+ const args = getRecord(info.args);
211
+ if (!args) return null;
212
+ const filePath = getString(args.filePath);
213
+ if (!filePath) return null;
214
+ const normalized = normalizePath(filePath);
215
+ const key = `edit:${normalized}`;
216
+ const summary = `[previous edit] ${normalized}`;
217
+ return { keys: [key], summary };
218
+ }
@@ -86,9 +86,16 @@ export function detectOAuth(
86
86
  * Used directly by runner-setup.ts (complex flow) and indirectly
87
87
  * by adaptSimpleCall (simple flows).
88
88
  */
89
- export function buildCodexProviderOptions(instructions: string) {
89
+ const CODEX_INSTRUCTIONS =
90
+ 'You are a coding agent. Follow all developer messages. Use tools to complete tasks.';
91
+
92
+ export function buildCodexProviderOptions() {
90
93
  return {
91
- openai: { store: false as const, instructions },
94
+ openai: {
95
+ store: false as const,
96
+ instructions: CODEX_INSTRUCTIONS,
97
+ parallelToolCalls: false,
98
+ },
92
99
  };
93
100
  }
94
101
 
@@ -132,13 +139,14 @@ export function adaptSimpleCall(
132
139
  ): AdaptedLLMCall {
133
140
  if (ctx.isOpenAIOAuth) {
134
141
  return {
142
+ system: input.instructions,
135
143
  messages: [
136
144
  {
137
145
  role: 'user',
138
- content: `${input.instructions}\n\n${input.userContent}`,
146
+ content: input.userContent,
139
147
  },
140
148
  ],
141
- providerOptions: buildCodexProviderOptions(input.instructions),
149
+ providerOptions: buildCodexProviderOptions(),
142
150
  forceStream: true,
143
151
  };
144
152
  }
@@ -184,7 +192,8 @@ export type AdaptedRunnerSetup = {
184
192
  * decides WHERE the already-composed system prompt goes:
185
193
  *
186
194
  * - **OpenAI OAuth (Codex)**: system='', composed prompt sent as a user
187
- * message in additionalSystemMessages, providerOptions with store=false
195
+ * system message in additionalSystemMessages (becomes developer role in
196
+ * Responses API), providerOptions with store=false
188
197
  * + instructions, maxOutputTokens stripped.
189
198
  *
190
199
  * - **Anthropic OAuth**: spoof prompt as system, composed prompt sent as
@@ -221,9 +230,9 @@ export function adaptRunnerCall(
221
230
  return {
222
231
  system: '',
223
232
  systemComponents: composed.components,
224
- additionalSystemMessages: [{ role: 'user', content: composed.prompt }],
233
+ additionalSystemMessages: [{ role: 'system', content: composed.prompt }],
225
234
  maxOutputTokens: undefined,
226
- providerOptions: buildCodexProviderOptions(composed.prompt),
235
+ providerOptions: buildCodexProviderOptions(),
227
236
  };
228
237
  }
229
238
 
@@ -24,7 +24,11 @@ export function createFinishHandler(
24
24
  return async (fin: FinishEvent) => {
25
25
  try {
26
26
  await completeAssistantMessageFn(fin, opts, db);
27
- } catch {}
27
+ } catch (err) {
28
+ debugLog(
29
+ `[finish-handler] completeAssistantMessage failed: ${err instanceof Error ? err.message : String(err)}`,
30
+ );
31
+ }
28
32
 
29
33
  if (opts.isCompactCommand && fin.finishReason !== 'error') {
30
34
  const assistantParts = await db
@@ -6,6 +6,7 @@ import type { RunOpts } from '../session/queue.ts';
6
6
  import type { ToolAdapterContext } from '../../tools/adapter.ts';
7
7
  import type { UsageData, ProviderMetadata } from '../session/db-operations.ts';
8
8
  import type { StepFinishEvent } from './types.ts';
9
+ import { debugLog } from '../debug/index.ts';
9
10
 
10
11
  export function createStepFinishHandler(
11
12
  opts: RunOpts,
@@ -41,7 +42,11 @@ export function createStepFinishHandler(
41
42
  .set({ completedAt: finishedAt })
42
43
  .where(eq(messageParts.id, currentPartId));
43
44
  }
44
- } catch {}
45
+ } catch (err) {
46
+ debugLog(
47
+ `[step-finish] Failed to update part completedAt: ${err instanceof Error ? err.message : String(err)}`,
48
+ );
49
+ }
45
50
 
46
51
  if (step.usage) {
47
52
  try {
@@ -51,7 +56,11 @@ export function createStepFinishHandler(
51
56
  opts,
52
57
  db,
53
58
  );
54
- } catch {}
59
+ } catch (err) {
60
+ debugLog(
61
+ `[step-finish] Failed to update session tokens: ${err instanceof Error ? err.message : String(err)}`,
62
+ );
63
+ }
55
64
 
56
65
  try {
57
66
  await updateMessageTokensIncrementalFn(
@@ -60,7 +69,11 @@ export function createStepFinishHandler(
60
69
  opts,
61
70
  db,
62
71
  );
63
- } catch {}
72
+ } catch (err) {
73
+ debugLog(
74
+ `[step-finish] Failed to update message tokens: ${err instanceof Error ? err.message : String(err)}`,
75
+ );
76
+ }
64
77
  }
65
78
 
66
79
  try {
@@ -81,13 +94,21 @@ export function createStepFinishHandler(
81
94
  payload: { stepIndex, ...step.usage },
82
95
  });
83
96
  }
84
- } catch {}
97
+ } catch (err) {
98
+ debugLog(
99
+ `[step-finish] Failed to publish finish-step: ${err instanceof Error ? err.message : String(err)}`,
100
+ );
101
+ }
85
102
 
86
103
  try {
87
104
  const newStepIndex = incrementStepIndex();
88
105
  sharedCtx.stepIndex = newStepIndex;
89
106
  updateCurrentPartId(null);
90
107
  updateAccumulated('');
91
- } catch {}
108
+ } catch (err) {
109
+ debugLog(
110
+ `[step-finish] Failed to increment step: ${err instanceof Error ? err.message : String(err)}`,
111
+ );
112
+ }
92
113
  };
93
114
  }
@@ -9,6 +9,7 @@ export const DANGEROUS_TOOLS = new Set([
9
9
  'apply_patch',
10
10
  'terminal',
11
11
  'edit',
12
+ 'multiedit',
12
13
  'git_commit',
13
14
  'git_push',
14
15
  ]);
@@ -39,6 +39,8 @@ export const CANONICAL_TO_PASCAL: Record<string, string> = {
39
39
 
40
40
  // Patch/edit
41
41
  apply_patch: 'ApplyPatch',
42
+ edit: 'Edit',
43
+ multiedit: 'MultiEdit',
42
44
 
43
45
  // Task management
44
46
  update_todos: 'UpdateTodos',
@@ -77,6 +79,8 @@ export const PASCAL_TO_CANONICAL: Record<string, string> = {
77
79
 
78
80
  // Patch/edit
79
81
  ApplyPatch: 'apply_patch',
82
+ Edit: 'edit',
83
+ MultiEdit: 'multiedit',
80
84
 
81
85
  // Task management
82
86
  UpdateTodos: 'update_todos',
@@ -52,6 +52,30 @@ function getPendingQueue(
52
52
  return queue;
53
53
  }
54
54
 
55
+ function unwrapDoubleWrappedArgs(
56
+ input: unknown,
57
+ expectedName: string,
58
+ ): typeof input {
59
+ if (
60
+ input &&
61
+ typeof input === 'object' &&
62
+ 'name' in input &&
63
+ 'args' in input &&
64
+ typeof (input as Record<string, unknown>).name === 'string' &&
65
+ typeof (input as Record<string, unknown>).args === 'object' &&
66
+ (input as Record<string, unknown>).args !== null
67
+ ) {
68
+ const wrapped = input as { name: string; args: Record<string, unknown> };
69
+ if (
70
+ wrapped.name === expectedName ||
71
+ wrapped.name.replace(/[_-]/g, '') === expectedName.replace(/[_-]/g, '')
72
+ ) {
73
+ return wrapped.args as typeof input;
74
+ }
75
+ }
76
+ return input;
77
+ }
78
+
55
79
  export function adaptTools(
56
80
  tools: DiscoveredTool[],
57
81
  ctx: ToolAdapterContext,
@@ -318,6 +342,7 @@ export function adaptTools(
318
342
  }
319
343
  },
320
344
  async execute(input: ToolExecuteInput, options: ToolExecuteOptions) {
345
+ input = unwrapDoubleWrappedArgs(input, name);
321
346
  const queue = pendingCalls.get(name);
322
347
  const meta = queue?.shift();
323
348
  if (queue && queue.length === 0) pendingCalls.delete(name);