@ottocode/server 0.1.173

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 (111) hide show
  1. package/package.json +42 -0
  2. package/src/events/bus.ts +43 -0
  3. package/src/events/types.ts +32 -0
  4. package/src/index.ts +281 -0
  5. package/src/openapi/helpers.ts +64 -0
  6. package/src/openapi/paths/ask.ts +70 -0
  7. package/src/openapi/paths/config.ts +218 -0
  8. package/src/openapi/paths/files.ts +72 -0
  9. package/src/openapi/paths/git.ts +457 -0
  10. package/src/openapi/paths/messages.ts +92 -0
  11. package/src/openapi/paths/sessions.ts +90 -0
  12. package/src/openapi/paths/setu.ts +154 -0
  13. package/src/openapi/paths/stream.ts +26 -0
  14. package/src/openapi/paths/terminals.ts +226 -0
  15. package/src/openapi/schemas.ts +345 -0
  16. package/src/openapi/spec.ts +49 -0
  17. package/src/presets.ts +85 -0
  18. package/src/routes/ask.ts +113 -0
  19. package/src/routes/auth.ts +592 -0
  20. package/src/routes/branch.ts +106 -0
  21. package/src/routes/config/agents.ts +44 -0
  22. package/src/routes/config/cwd.ts +21 -0
  23. package/src/routes/config/defaults.ts +45 -0
  24. package/src/routes/config/index.ts +16 -0
  25. package/src/routes/config/main.ts +73 -0
  26. package/src/routes/config/models.ts +139 -0
  27. package/src/routes/config/providers.ts +46 -0
  28. package/src/routes/config/utils.ts +120 -0
  29. package/src/routes/files.ts +218 -0
  30. package/src/routes/git/branch.ts +75 -0
  31. package/src/routes/git/commit.ts +209 -0
  32. package/src/routes/git/diff.ts +137 -0
  33. package/src/routes/git/index.ts +18 -0
  34. package/src/routes/git/push.ts +160 -0
  35. package/src/routes/git/schemas.ts +48 -0
  36. package/src/routes/git/staging.ts +208 -0
  37. package/src/routes/git/status.ts +83 -0
  38. package/src/routes/git/types.ts +31 -0
  39. package/src/routes/git/utils.ts +249 -0
  40. package/src/routes/openapi.ts +6 -0
  41. package/src/routes/research.ts +392 -0
  42. package/src/routes/root.ts +5 -0
  43. package/src/routes/session-approval.ts +63 -0
  44. package/src/routes/session-files.ts +387 -0
  45. package/src/routes/session-messages.ts +170 -0
  46. package/src/routes/session-stream.ts +61 -0
  47. package/src/routes/sessions.ts +814 -0
  48. package/src/routes/setu.ts +346 -0
  49. package/src/routes/terminals.ts +227 -0
  50. package/src/runtime/agent/registry.ts +351 -0
  51. package/src/runtime/agent/runner-reasoning.ts +108 -0
  52. package/src/runtime/agent/runner-setup.ts +257 -0
  53. package/src/runtime/agent/runner.ts +375 -0
  54. package/src/runtime/agent-registry.ts +6 -0
  55. package/src/runtime/ask/service.ts +369 -0
  56. package/src/runtime/context/environment.ts +202 -0
  57. package/src/runtime/debug/index.ts +117 -0
  58. package/src/runtime/debug/state.ts +140 -0
  59. package/src/runtime/errors/api-error.ts +192 -0
  60. package/src/runtime/errors/handling.ts +199 -0
  61. package/src/runtime/message/compaction-auto.ts +154 -0
  62. package/src/runtime/message/compaction-context.ts +101 -0
  63. package/src/runtime/message/compaction-detect.ts +26 -0
  64. package/src/runtime/message/compaction-limits.ts +37 -0
  65. package/src/runtime/message/compaction-mark.ts +111 -0
  66. package/src/runtime/message/compaction-prune.ts +75 -0
  67. package/src/runtime/message/compaction.ts +21 -0
  68. package/src/runtime/message/history-builder.ts +266 -0
  69. package/src/runtime/message/service.ts +468 -0
  70. package/src/runtime/message/tool-history-tracker.ts +204 -0
  71. package/src/runtime/prompt/builder.ts +167 -0
  72. package/src/runtime/provider/anthropic.ts +50 -0
  73. package/src/runtime/provider/copilot.ts +12 -0
  74. package/src/runtime/provider/google.ts +8 -0
  75. package/src/runtime/provider/index.ts +60 -0
  76. package/src/runtime/provider/moonshot.ts +8 -0
  77. package/src/runtime/provider/oauth-adapter.ts +237 -0
  78. package/src/runtime/provider/openai.ts +18 -0
  79. package/src/runtime/provider/opencode.ts +7 -0
  80. package/src/runtime/provider/openrouter.ts +7 -0
  81. package/src/runtime/provider/selection.ts +118 -0
  82. package/src/runtime/provider/setu.ts +126 -0
  83. package/src/runtime/provider/zai.ts +16 -0
  84. package/src/runtime/session/branch.ts +280 -0
  85. package/src/runtime/session/db-operations.ts +285 -0
  86. package/src/runtime/session/manager.ts +99 -0
  87. package/src/runtime/session/queue.ts +243 -0
  88. package/src/runtime/stream/abort-handler.ts +65 -0
  89. package/src/runtime/stream/error-handler.ts +371 -0
  90. package/src/runtime/stream/finish-handler.ts +101 -0
  91. package/src/runtime/stream/handlers.ts +5 -0
  92. package/src/runtime/stream/step-finish.ts +93 -0
  93. package/src/runtime/stream/types.ts +25 -0
  94. package/src/runtime/tools/approval.ts +180 -0
  95. package/src/runtime/tools/context.ts +83 -0
  96. package/src/runtime/tools/mapping.ts +154 -0
  97. package/src/runtime/tools/setup.ts +44 -0
  98. package/src/runtime/topup/manager.ts +110 -0
  99. package/src/runtime/utils/cwd.ts +69 -0
  100. package/src/runtime/utils/token.ts +35 -0
  101. package/src/tools/adapter.ts +634 -0
  102. package/src/tools/database/get-parent-session.ts +183 -0
  103. package/src/tools/database/get-session-context.ts +161 -0
  104. package/src/tools/database/index.ts +42 -0
  105. package/src/tools/database/present-session-links.ts +47 -0
  106. package/src/tools/database/query-messages.ts +160 -0
  107. package/src/tools/database/query-sessions.ts +126 -0
  108. package/src/tools/database/search-history.ts +135 -0
  109. package/src/types/sql-imports.d.ts +5 -0
  110. package/sst-env.d.ts +8 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,180 @@
1
+ import { publish } from '../../events/bus.ts';
2
+ import { debugLog } from '../debug/index.ts';
3
+
4
+ export type ToolApprovalMode = 'auto' | 'dangerous' | 'all';
5
+
6
+ export const DANGEROUS_TOOLS = new Set([
7
+ 'bash',
8
+ 'write',
9
+ 'apply_patch',
10
+ 'terminal',
11
+ 'edit',
12
+ 'git_commit',
13
+ 'git_push',
14
+ ]);
15
+
16
+ export const SAFE_TOOLS = new Set([
17
+ 'finish',
18
+ 'progress_update',
19
+ 'update_todos',
20
+ ]);
21
+
22
+ export interface PendingApproval {
23
+ callId: string;
24
+ toolName: string;
25
+ args: unknown;
26
+ sessionId: string;
27
+ messageId: string;
28
+ resolve: (approved: boolean) => void;
29
+ createdAt: number;
30
+ }
31
+
32
+ const pendingApprovals = new Map<string, PendingApproval>();
33
+
34
+ export function requiresApproval(
35
+ toolName: string,
36
+ mode: ToolApprovalMode,
37
+ ): boolean {
38
+ if (SAFE_TOOLS.has(toolName)) return false;
39
+ if (mode === 'auto') return false;
40
+ if (mode === 'all') return true;
41
+ if (mode === 'dangerous') return DANGEROUS_TOOLS.has(toolName);
42
+ return false;
43
+ }
44
+
45
+ export async function requestApproval(
46
+ sessionId: string,
47
+ messageId: string,
48
+ callId: string,
49
+ toolName: string,
50
+ args: unknown,
51
+ timeoutMs = 120000,
52
+ ): Promise<boolean> {
53
+ debugLog('[approval] requestApproval called', {
54
+ sessionId,
55
+ messageId,
56
+ callId,
57
+ toolName,
58
+ });
59
+ return new Promise((resolve) => {
60
+ const approval: PendingApproval = {
61
+ callId,
62
+ toolName,
63
+ args,
64
+ sessionId,
65
+ messageId,
66
+ resolve,
67
+ createdAt: Date.now(),
68
+ };
69
+
70
+ pendingApprovals.set(callId, approval);
71
+ debugLog(
72
+ '[approval] Added to pendingApprovals, count:',
73
+ pendingApprovals.size,
74
+ );
75
+
76
+ publish({
77
+ type: 'tool.approval.required',
78
+ sessionId,
79
+ payload: {
80
+ callId,
81
+ toolName,
82
+ args,
83
+ messageId,
84
+ },
85
+ });
86
+
87
+ setTimeout(() => {
88
+ if (pendingApprovals.has(callId)) {
89
+ pendingApprovals.delete(callId);
90
+ resolve(false);
91
+ publish({
92
+ type: 'tool.approval.resolved',
93
+ sessionId,
94
+ payload: {
95
+ callId,
96
+ toolName,
97
+ approved: false,
98
+ reason: 'timeout',
99
+ },
100
+ });
101
+ }
102
+ }, timeoutMs);
103
+ });
104
+ }
105
+
106
+ export function resolveApproval(
107
+ callId: string,
108
+ approved: boolean,
109
+ ): { ok: boolean; error?: string } {
110
+ debugLog('[approval] resolveApproval called', {
111
+ callId,
112
+ approved,
113
+ pendingCount: pendingApprovals.size,
114
+ pendingIds: [...pendingApprovals.keys()],
115
+ });
116
+ const approval = pendingApprovals.get(callId);
117
+ if (!approval) {
118
+ debugLog('[approval] No pending approval found for callId:', callId);
119
+ return { ok: false, error: 'No pending approval found for this callId' };
120
+ }
121
+
122
+ pendingApprovals.delete(callId);
123
+ approval.resolve(approved);
124
+
125
+ publish({
126
+ type: 'tool.approval.resolved',
127
+ sessionId: approval.sessionId,
128
+ payload: {
129
+ callId,
130
+ toolName: approval.toolName,
131
+ approved,
132
+ reason: approved ? 'user_approved' : 'user_rejected',
133
+ },
134
+ });
135
+
136
+ return { ok: true };
137
+ }
138
+
139
+ export function getPendingApproval(
140
+ callId: string,
141
+ ): PendingApproval | undefined {
142
+ return pendingApprovals.get(callId);
143
+ }
144
+
145
+ export function updateApprovalArgs(callId: string, args: unknown): boolean {
146
+ const approval = pendingApprovals.get(callId);
147
+ if (!approval) return false;
148
+
149
+ approval.args = args;
150
+
151
+ publish({
152
+ type: 'tool.approval.updated',
153
+ sessionId: approval.sessionId,
154
+ payload: {
155
+ callId,
156
+ toolName: approval.toolName,
157
+ args,
158
+ messageId: approval.messageId,
159
+ },
160
+ });
161
+
162
+ return true;
163
+ }
164
+
165
+ export function getPendingApprovalsForSession(
166
+ sessionId: string,
167
+ ): PendingApproval[] {
168
+ return Array.from(pendingApprovals.values()).filter(
169
+ (a) => a.sessionId === sessionId,
170
+ );
171
+ }
172
+
173
+ export function clearPendingApprovalsForSession(sessionId: string): void {
174
+ for (const [callId, approval] of pendingApprovals) {
175
+ if (approval.sessionId === sessionId) {
176
+ approval.resolve(false);
177
+ pendingApprovals.delete(callId);
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,83 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import type { DB } from '@ottocode/database';
3
+ import { messageParts } from '@ottocode/database/schema';
4
+ import type { ToolApprovalMode } from './approval.ts';
5
+ import { publish } from '../../events/bus.ts';
6
+
7
+ export type StepExecutionState = {
8
+ chain: Promise<void>;
9
+ failed: boolean;
10
+ failedToolName?: string;
11
+ };
12
+
13
+ export type ToolAdapterContext = {
14
+ sessionId: string;
15
+ messageId: string;
16
+ assistantPartId: string;
17
+ db: DB;
18
+ agent: string;
19
+ provider: string;
20
+ model: string;
21
+ projectRoot: string;
22
+ nextIndex: () => number | Promise<number>;
23
+ stepIndex?: number;
24
+ onFirstToolCall?: () => void;
25
+ stepExecution?: {
26
+ states: Map<number, StepExecutionState>;
27
+ };
28
+ toolApprovalMode?: ToolApprovalMode;
29
+ };
30
+
31
+ export function extractFinishText(input: unknown): string | undefined {
32
+ if (typeof input === 'string') return input;
33
+ if (!input || typeof input !== 'object') return undefined;
34
+ const obj = input as Record<string, unknown>;
35
+ if (typeof obj.text === 'string') return obj.text;
36
+ if (
37
+ obj.input &&
38
+ typeof (obj.input as Record<string, unknown>).text === 'string'
39
+ )
40
+ return String((obj.input as Record<string, unknown>).text);
41
+ return undefined;
42
+ }
43
+
44
+ export async function appendAssistantText(
45
+ ctx: ToolAdapterContext,
46
+ text: string,
47
+ ): Promise<void> {
48
+ try {
49
+ const rows = await ctx.db
50
+ .select()
51
+ .from(messageParts)
52
+ .where(eq(messageParts.id, ctx.assistantPartId));
53
+ let previous = '';
54
+ if (rows.length) {
55
+ try {
56
+ const parsed = JSON.parse(rows[0]?.content ?? '{}');
57
+ if (parsed && typeof parsed.text === 'string') previous = parsed.text;
58
+ } catch {}
59
+ }
60
+ const addition = text.startsWith(previous)
61
+ ? text.slice(previous.length)
62
+ : text;
63
+ if (addition.length) {
64
+ const payload: Record<string, unknown> = {
65
+ messageId: ctx.messageId,
66
+ partId: ctx.assistantPartId,
67
+ delta: addition,
68
+ };
69
+ if (ctx.stepIndex !== undefined) payload.stepIndex = ctx.stepIndex;
70
+ publish({
71
+ type: 'message.part.delta',
72
+ sessionId: ctx.sessionId,
73
+ payload,
74
+ });
75
+ }
76
+ await ctx.db
77
+ .update(messageParts)
78
+ .set({ content: JSON.stringify({ text }) })
79
+ .where(eq(messageParts.id, ctx.assistantPartId));
80
+ } catch {
81
+ // ignore to keep run alive if we can't persist the text
82
+ }
83
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tool name mapping for Claude Code OAuth compatibility.
3
+ *
4
+ * Claude Code OAuth requires PascalCase tool names but does NOT whitelist
5
+ * specific tools. Any tool with a PascalCase name is accepted.
6
+ *
7
+ * This module provides bidirectional mapping between otto's canonical
8
+ * snake_case names and the PascalCase format required for OAuth.
9
+ */
10
+
11
+ export type ToolNamingConvention = 'canonical' | 'claude-code';
12
+
13
+ /**
14
+ * Mapping from otto canonical names to PascalCase names.
15
+ * Includes ALL otto tools for complete OAuth compatibility.
16
+ */
17
+ export const CANONICAL_TO_PASCAL: Record<string, string> = {
18
+ // File system operations
19
+ read: 'Read',
20
+ write: 'Write',
21
+ ls: 'Ls',
22
+ tree: 'Tree',
23
+ cd: 'Cd',
24
+ pwd: 'Pwd',
25
+
26
+ // Search operations
27
+ glob: 'Glob',
28
+ ripgrep: 'Grep', // Maps to Grep for Claude Code compatibility
29
+ grep: 'Grep',
30
+
31
+ // Execution
32
+ bash: 'Bash',
33
+ terminal: 'Terminal',
34
+
35
+ // Git operations
36
+ git_status: 'GitStatus',
37
+ git_diff: 'GitDiff',
38
+ git_commit: 'GitCommit',
39
+
40
+ // Patch/edit
41
+ apply_patch: 'ApplyPatch',
42
+
43
+ // Task management
44
+ update_todos: 'UpdateTodos',
45
+ progress_update: 'ProgressUpdate',
46
+ finish: 'Finish',
47
+
48
+ // Web operations
49
+ websearch: 'WebSearch',
50
+ };
51
+
52
+ /**
53
+ * Reverse mapping from PascalCase names to canonical.
54
+ * Built to handle the many-to-one ripgrep/grep → Grep mapping.
55
+ */
56
+ export const PASCAL_TO_CANONICAL: Record<string, string> = {
57
+ // File system operations
58
+ Read: 'read',
59
+ Write: 'write',
60
+ Ls: 'ls',
61
+ Tree: 'tree',
62
+ Cd: 'cd',
63
+ Pwd: 'pwd',
64
+
65
+ // Search operations
66
+ Glob: 'glob',
67
+ Grep: 'ripgrep', // Maps back to ripgrep (primary search tool)
68
+
69
+ // Execution
70
+ Bash: 'bash',
71
+ Terminal: 'terminal',
72
+
73
+ // Git operations
74
+ GitStatus: 'git_status',
75
+ GitDiff: 'git_diff',
76
+ GitCommit: 'git_commit',
77
+
78
+ // Patch/edit
79
+ ApplyPatch: 'apply_patch',
80
+
81
+ // Task management
82
+ UpdateTodos: 'update_todos',
83
+ ProgressUpdate: 'progress_update',
84
+ Finish: 'finish',
85
+
86
+ // Web operations
87
+ WebSearch: 'websearch',
88
+ };
89
+
90
+ /**
91
+ * Convert a canonical tool name to PascalCase format.
92
+ */
93
+ export function toClaudeCodeName(canonical: string): string {
94
+ if (CANONICAL_TO_PASCAL[canonical]) {
95
+ return CANONICAL_TO_PASCAL[canonical];
96
+ }
97
+ // Default: convert snake_case to PascalCase
98
+ return canonical
99
+ .split('_')
100
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
101
+ .join('');
102
+ }
103
+
104
+ /**
105
+ * Convert a PascalCase tool name to canonical format.
106
+ */
107
+ export function toCanonicalName(pascalCase: string): string {
108
+ if (PASCAL_TO_CANONICAL[pascalCase]) {
109
+ return PASCAL_TO_CANONICAL[pascalCase];
110
+ }
111
+ // Default: convert PascalCase to snake_case
112
+ return pascalCase
113
+ .replace(/([A-Z])/g, '_$1')
114
+ .toLowerCase()
115
+ .replace(/^_/, '');
116
+ }
117
+
118
+ /**
119
+ * Check if the current provider/auth combo requires PascalCase naming.
120
+ */
121
+ export function requiresClaudeCodeNaming(
122
+ provider: string,
123
+ authType?: string,
124
+ ): boolean {
125
+ return provider === 'anthropic' && authType === 'oauth';
126
+ }
127
+
128
+ /**
129
+ * Transform a tool definition for Claude Code OAuth.
130
+ * Returns a new object with the transformed name.
131
+ */
132
+ export function transformToolForClaudeCode<T extends { name: string }>(
133
+ tool: T,
134
+ ): T {
135
+ return {
136
+ ...tool,
137
+ name: toClaudeCodeName(tool.name),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Transform tool call arguments to canonical names.
143
+ * Used when receiving tool calls from Claude Code OAuth.
144
+ */
145
+ export function normalizeToolCall<T extends { name: string }>(
146
+ call: T,
147
+ fromClaudeCode: boolean,
148
+ ): T {
149
+ if (!fromClaudeCode) return call;
150
+ return {
151
+ ...call,
152
+ name: toCanonicalName(call.name),
153
+ };
154
+ }
@@ -0,0 +1,44 @@
1
+ import type { getDb } from '@ottocode/database';
2
+ import { time } from '../debug/index.ts';
3
+ import type { ToolAdapterContext } from '../../tools/adapter.ts';
4
+ import type { RunOpts } from '../session/queue.ts';
5
+
6
+ export type RunnerToolContext = ToolAdapterContext & { stepIndex: number };
7
+
8
+ /**
9
+ * Sets up the shared tool context for a run, including the index counter
10
+ * and first tool call tracking.
11
+ */
12
+ export async function setupToolContext(
13
+ opts: RunOpts,
14
+ db: Awaited<ReturnType<typeof getDb>>,
15
+ ) {
16
+ const firstToolTimer = time('runner:first-tool-call');
17
+ let firstToolSeen = false;
18
+
19
+ // Simple counter starting at 0 - first event gets 0, second gets 1, etc.
20
+ let currentIndex = 0;
21
+ const nextIndex = () => currentIndex++;
22
+
23
+ const sharedCtx: RunnerToolContext = {
24
+ nextIndex,
25
+ stepIndex: 0,
26
+ sessionId: opts.sessionId,
27
+ messageId: opts.assistantMessageId,
28
+ assistantPartId: '', // Will be set by runner when first text part is created
29
+ db,
30
+ agent: opts.agent,
31
+ provider: opts.provider,
32
+ model: opts.model,
33
+ projectRoot: opts.projectRoot,
34
+ stepExecution: { states: new Map() },
35
+ toolApprovalMode: opts.toolApprovalMode,
36
+ onFirstToolCall: () => {
37
+ if (firstToolSeen) return;
38
+ firstToolSeen = true;
39
+ firstToolTimer.end();
40
+ },
41
+ };
42
+
43
+ return { sharedCtx, firstToolTimer, firstToolSeen: () => firstToolSeen };
44
+ }
@@ -0,0 +1,110 @@
1
+ import { logger } from '@ottocode/sdk';
2
+
3
+ export type TopupMethod = 'crypto' | 'fiat';
4
+
5
+ export interface PendingTopup {
6
+ sessionId: string;
7
+ messageId: string;
8
+ amountUsd: number;
9
+ currentBalance: number;
10
+ resolve: (method: TopupMethod) => void;
11
+ reject: (error: Error) => void;
12
+ createdAt: number;
13
+ }
14
+
15
+ const TOPUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
16
+
17
+ const pendingTopups = new Map<string, PendingTopup>();
18
+ const timeoutIds = new Map<string, ReturnType<typeof setTimeout>>();
19
+
20
+ export function waitForTopupMethodSelection(
21
+ sessionId: string,
22
+ messageId: string,
23
+ amountUsd: number,
24
+ currentBalance: number,
25
+ ): Promise<TopupMethod> {
26
+ return new Promise((resolve, reject) => {
27
+ const existing = pendingTopups.get(sessionId);
28
+ if (existing) {
29
+ existing.reject(new Error('Superseded by new topup request'));
30
+ clearPendingTopup(sessionId);
31
+ }
32
+
33
+ const pending: PendingTopup = {
34
+ sessionId,
35
+ messageId,
36
+ amountUsd,
37
+ currentBalance,
38
+ resolve,
39
+ reject,
40
+ createdAt: Date.now(),
41
+ };
42
+
43
+ pendingTopups.set(sessionId, pending);
44
+
45
+ const timeoutId = setTimeout(() => {
46
+ const p = pendingTopups.get(sessionId);
47
+ if (p) {
48
+ logger.warn(`Topup selection timeout for session ${sessionId}`);
49
+ p.reject(new Error('Topup selection timeout'));
50
+ clearPendingTopup(sessionId);
51
+ }
52
+ }, TOPUP_TIMEOUT_MS);
53
+
54
+ timeoutIds.set(sessionId, timeoutId);
55
+ });
56
+ }
57
+
58
+ export function resolveTopupMethodSelection(
59
+ sessionId: string,
60
+ method: TopupMethod,
61
+ ): boolean {
62
+ const pending = pendingTopups.get(sessionId);
63
+ if (!pending) {
64
+ logger.warn(
65
+ `No pending topup found for session ${sessionId} when trying to resolve`,
66
+ );
67
+ return false;
68
+ }
69
+
70
+ pending.resolve(method);
71
+ clearPendingTopup(sessionId);
72
+ return true;
73
+ }
74
+
75
+ export function rejectTopupSelection(
76
+ sessionId: string,
77
+ reason: string,
78
+ ): boolean {
79
+ const pending = pendingTopups.get(sessionId);
80
+ if (!pending) {
81
+ return false;
82
+ }
83
+
84
+ pending.reject(new Error(reason));
85
+ clearPendingTopup(sessionId);
86
+ return true;
87
+ }
88
+
89
+ export function getPendingTopup(sessionId: string): PendingTopup | undefined {
90
+ return pendingTopups.get(sessionId);
91
+ }
92
+
93
+ export function hasPendingTopup(sessionId: string): boolean {
94
+ return pendingTopups.has(sessionId);
95
+ }
96
+
97
+ export function clearPendingTopup(sessionId: string): void {
98
+ const timeoutId = timeoutIds.get(sessionId);
99
+ if (timeoutId) {
100
+ clearTimeout(timeoutId);
101
+ timeoutIds.delete(sessionId);
102
+ }
103
+ pendingTopups.delete(sessionId);
104
+ }
105
+
106
+ export function clearAllPendingTopups(): void {
107
+ for (const sessionId of pendingTopups.keys()) {
108
+ clearPendingTopup(sessionId);
109
+ }
110
+ }
@@ -0,0 +1,69 @@
1
+ const cwdMap = new Map<string, string>();
2
+
3
+ export function getCwd(sessionId: string): string {
4
+ return cwdMap.get(sessionId) ?? '.';
5
+ }
6
+
7
+ export function setCwd(sessionId: string, cwd: string) {
8
+ cwdMap.set(sessionId, cwd || '.');
9
+ }
10
+
11
+ // normalize relative path like './a/../b' -> 'b', never escapes above '.'
12
+ export function normalizeRelative(path: string): string {
13
+ const parts = path.replace(/\\/g, '/').split('/');
14
+ const stack: string[] = [];
15
+ for (const part of parts) {
16
+ if (!part || part === '.') continue;
17
+ if (part === '..') {
18
+ if (stack.length > 0) stack.pop();
19
+ continue;
20
+ }
21
+ stack.push(part);
22
+ }
23
+ return stack.length ? stack.join('/') : '.';
24
+ }
25
+
26
+ export function joinRelative(base: string, p: string): string {
27
+ if (!p || p === '.') return base || '.';
28
+
29
+ // Expand tilde to home directory if present
30
+ const home = process.env.HOME || process.env.USERPROFILE || '';
31
+ if (p === '~' && home) p = home;
32
+ else if (p.startsWith('~/') && home) p = `${home}/${p.slice(2)}`;
33
+
34
+ // If target is absolute, preserve it as absolute
35
+ if (p.startsWith('/')) {
36
+ // Canonicalize: collapse //, resolve . and .. without escaping above root
37
+ const parts = p.replace(/\\/g, '/').split('/');
38
+ const stack: string[] = [];
39
+ for (const part of parts) {
40
+ if (!part || part === '.') continue;
41
+ if (part === '..') {
42
+ if (stack.length > 0) stack.pop();
43
+ continue;
44
+ }
45
+ stack.push(part);
46
+ }
47
+ return `/${stack.join('/')}` || '/';
48
+ }
49
+
50
+ // If base is absolute, join and return absolute
51
+ const baseIsAbs = Boolean(base) && base.startsWith('/');
52
+ if (baseIsAbs) {
53
+ const joined = `${base.replace(/\/$/, '')}/${p}`;
54
+ const parts = joined.replace(/\\/g, '/').split('/');
55
+ const stack: string[] = [];
56
+ for (const part of parts) {
57
+ if (!part || part === '.') continue;
58
+ if (part === '..') {
59
+ if (stack.length > 0) stack.pop();
60
+ continue;
61
+ }
62
+ stack.push(part);
63
+ }
64
+ return `/${stack.join('/')}` || '/';
65
+ }
66
+
67
+ // Relative -> Relative join
68
+ return normalizeRelative(`${base || '.'}/${p}`);
69
+ }
@@ -0,0 +1,35 @@
1
+ import { catalog } from '@ottocode/sdk';
2
+ import { debugLog } from '../debug/index.ts';
3
+ import type { ProviderName } from '../provider/index.ts';
4
+
5
+ /**
6
+ * Gets the maximum output tokens allowed for a given provider/model combination.
7
+ * Returns undefined if the information is not available in the catalog.
8
+ */
9
+ export function getMaxOutputTokens(
10
+ provider: ProviderName,
11
+ modelId: string,
12
+ ): number | undefined {
13
+ try {
14
+ const providerCatalog = catalog[provider];
15
+ if (!providerCatalog) {
16
+ debugLog(`[maxOutputTokens] No catalog found for provider: ${provider}`);
17
+ return undefined;
18
+ }
19
+ const modelInfo = providerCatalog.models.find((m) => m.id === modelId);
20
+ if (!modelInfo) {
21
+ debugLog(
22
+ `[maxOutputTokens] No model info found for: ${modelId} in provider: ${provider}`,
23
+ );
24
+ return undefined;
25
+ }
26
+ const outputLimit = modelInfo.limit?.output;
27
+ debugLog(
28
+ `[maxOutputTokens] Provider: ${provider}, Model: ${modelId}, Limit: ${outputLimit}`,
29
+ );
30
+ return outputLimit;
31
+ } catch (err) {
32
+ debugLog(`[maxOutputTokens] Error looking up limit: ${err}`);
33
+ return undefined;
34
+ }
35
+ }