@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.
- package/package.json +42 -0
- package/src/events/bus.ts +43 -0
- package/src/events/types.ts +32 -0
- package/src/index.ts +281 -0
- package/src/openapi/helpers.ts +64 -0
- package/src/openapi/paths/ask.ts +70 -0
- package/src/openapi/paths/config.ts +218 -0
- package/src/openapi/paths/files.ts +72 -0
- package/src/openapi/paths/git.ts +457 -0
- package/src/openapi/paths/messages.ts +92 -0
- package/src/openapi/paths/sessions.ts +90 -0
- package/src/openapi/paths/setu.ts +154 -0
- package/src/openapi/paths/stream.ts +26 -0
- package/src/openapi/paths/terminals.ts +226 -0
- package/src/openapi/schemas.ts +345 -0
- package/src/openapi/spec.ts +49 -0
- package/src/presets.ts +85 -0
- package/src/routes/ask.ts +113 -0
- package/src/routes/auth.ts +592 -0
- package/src/routes/branch.ts +106 -0
- package/src/routes/config/agents.ts +44 -0
- package/src/routes/config/cwd.ts +21 -0
- package/src/routes/config/defaults.ts +45 -0
- package/src/routes/config/index.ts +16 -0
- package/src/routes/config/main.ts +73 -0
- package/src/routes/config/models.ts +139 -0
- package/src/routes/config/providers.ts +46 -0
- package/src/routes/config/utils.ts +120 -0
- package/src/routes/files.ts +218 -0
- package/src/routes/git/branch.ts +75 -0
- package/src/routes/git/commit.ts +209 -0
- package/src/routes/git/diff.ts +137 -0
- package/src/routes/git/index.ts +18 -0
- package/src/routes/git/push.ts +160 -0
- package/src/routes/git/schemas.ts +48 -0
- package/src/routes/git/staging.ts +208 -0
- package/src/routes/git/status.ts +83 -0
- package/src/routes/git/types.ts +31 -0
- package/src/routes/git/utils.ts +249 -0
- package/src/routes/openapi.ts +6 -0
- package/src/routes/research.ts +392 -0
- package/src/routes/root.ts +5 -0
- package/src/routes/session-approval.ts +63 -0
- package/src/routes/session-files.ts +387 -0
- package/src/routes/session-messages.ts +170 -0
- package/src/routes/session-stream.ts +61 -0
- package/src/routes/sessions.ts +814 -0
- package/src/routes/setu.ts +346 -0
- package/src/routes/terminals.ts +227 -0
- package/src/runtime/agent/registry.ts +351 -0
- package/src/runtime/agent/runner-reasoning.ts +108 -0
- package/src/runtime/agent/runner-setup.ts +257 -0
- package/src/runtime/agent/runner.ts +375 -0
- package/src/runtime/agent-registry.ts +6 -0
- package/src/runtime/ask/service.ts +369 -0
- package/src/runtime/context/environment.ts +202 -0
- package/src/runtime/debug/index.ts +117 -0
- package/src/runtime/debug/state.ts +140 -0
- package/src/runtime/errors/api-error.ts +192 -0
- package/src/runtime/errors/handling.ts +199 -0
- package/src/runtime/message/compaction-auto.ts +154 -0
- package/src/runtime/message/compaction-context.ts +101 -0
- package/src/runtime/message/compaction-detect.ts +26 -0
- package/src/runtime/message/compaction-limits.ts +37 -0
- package/src/runtime/message/compaction-mark.ts +111 -0
- package/src/runtime/message/compaction-prune.ts +75 -0
- package/src/runtime/message/compaction.ts +21 -0
- package/src/runtime/message/history-builder.ts +266 -0
- package/src/runtime/message/service.ts +468 -0
- package/src/runtime/message/tool-history-tracker.ts +204 -0
- package/src/runtime/prompt/builder.ts +167 -0
- package/src/runtime/provider/anthropic.ts +50 -0
- package/src/runtime/provider/copilot.ts +12 -0
- package/src/runtime/provider/google.ts +8 -0
- package/src/runtime/provider/index.ts +60 -0
- package/src/runtime/provider/moonshot.ts +8 -0
- package/src/runtime/provider/oauth-adapter.ts +237 -0
- package/src/runtime/provider/openai.ts +18 -0
- package/src/runtime/provider/opencode.ts +7 -0
- package/src/runtime/provider/openrouter.ts +7 -0
- package/src/runtime/provider/selection.ts +118 -0
- package/src/runtime/provider/setu.ts +126 -0
- package/src/runtime/provider/zai.ts +16 -0
- package/src/runtime/session/branch.ts +280 -0
- package/src/runtime/session/db-operations.ts +285 -0
- package/src/runtime/session/manager.ts +99 -0
- package/src/runtime/session/queue.ts +243 -0
- package/src/runtime/stream/abort-handler.ts +65 -0
- package/src/runtime/stream/error-handler.ts +371 -0
- package/src/runtime/stream/finish-handler.ts +101 -0
- package/src/runtime/stream/handlers.ts +5 -0
- package/src/runtime/stream/step-finish.ts +93 -0
- package/src/runtime/stream/types.ts +25 -0
- package/src/runtime/tools/approval.ts +180 -0
- package/src/runtime/tools/context.ts +83 -0
- package/src/runtime/tools/mapping.ts +154 -0
- package/src/runtime/tools/setup.ts +44 -0
- package/src/runtime/topup/manager.ts +110 -0
- package/src/runtime/utils/cwd.ts +69 -0
- package/src/runtime/utils/token.ts +35 -0
- package/src/tools/adapter.ts +634 -0
- package/src/tools/database/get-parent-session.ts +183 -0
- package/src/tools/database/get-session-context.ts +161 -0
- package/src/tools/database/index.ts +42 -0
- package/src/tools/database/present-session-links.ts +47 -0
- package/src/tools/database/query-messages.ts +160 -0
- package/src/tools/database/query-sessions.ts +126 -0
- package/src/tools/database/search-history.ts +135 -0
- package/src/types/sql-imports.d.ts +5 -0
- package/sst-env.d.ts +8 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import {
|
|
3
|
+
fetchSetuBalance,
|
|
4
|
+
getPublicKeyFromPrivate,
|
|
5
|
+
getAuth,
|
|
6
|
+
loadConfig,
|
|
7
|
+
fetchSolanaUsdcBalance,
|
|
8
|
+
} from '@ottocode/sdk';
|
|
9
|
+
import { logger } from '@ottocode/sdk';
|
|
10
|
+
import { serializeError } from '../runtime/errors/api-error.ts';
|
|
11
|
+
import { Keypair } from '@solana/web3.js';
|
|
12
|
+
import bs58 from 'bs58';
|
|
13
|
+
import nacl from 'tweetnacl';
|
|
14
|
+
import { publish } from '../events/bus.ts';
|
|
15
|
+
import {
|
|
16
|
+
resolveTopupMethodSelection,
|
|
17
|
+
rejectTopupSelection,
|
|
18
|
+
getPendingTopup,
|
|
19
|
+
type TopupMethod,
|
|
20
|
+
} from '../runtime/topup/manager.ts';
|
|
21
|
+
|
|
22
|
+
const SETU_BASE_URL = process.env.SETU_BASE_URL || 'https://api.setu.nitish.sh';
|
|
23
|
+
|
|
24
|
+
function getSetuBaseUrl(): string {
|
|
25
|
+
return SETU_BASE_URL.endsWith('/')
|
|
26
|
+
? SETU_BASE_URL.slice(0, -1)
|
|
27
|
+
: SETU_BASE_URL;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getSetuPrivateKey(): Promise<string | null> {
|
|
31
|
+
if (process.env.SETU_PRIVATE_KEY) {
|
|
32
|
+
return process.env.SETU_PRIVATE_KEY;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const cfg = await loadConfig(process.cwd());
|
|
37
|
+
const auth = await getAuth('setu', cfg.projectRoot);
|
|
38
|
+
if (auth?.type === 'wallet' && auth.secret) {
|
|
39
|
+
return auth.secret;
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function signNonce(nonce: string, privateKeyBytes: Uint8Array): string {
|
|
47
|
+
const data = new TextEncoder().encode(nonce);
|
|
48
|
+
const signature = nacl.sign.detached(data, privateKeyBytes);
|
|
49
|
+
return bs58.encode(signature);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildWalletHeaders(privateKey: string): Record<string, string> {
|
|
53
|
+
const privateKeyBytes = bs58.decode(privateKey);
|
|
54
|
+
const keypair = Keypair.fromSecretKey(privateKeyBytes);
|
|
55
|
+
const walletAddress = keypair.publicKey.toBase58();
|
|
56
|
+
const nonce = Date.now().toString();
|
|
57
|
+
const signature = signNonce(nonce, privateKeyBytes);
|
|
58
|
+
return {
|
|
59
|
+
'x-wallet-address': walletAddress,
|
|
60
|
+
'x-wallet-nonce': nonce,
|
|
61
|
+
'x-wallet-signature': signature,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function registerSetuRoutes(app: Hono) {
|
|
66
|
+
app.get('/v1/setu/balance', async (c) => {
|
|
67
|
+
try {
|
|
68
|
+
const privateKey = await getSetuPrivateKey();
|
|
69
|
+
if (!privateKey) {
|
|
70
|
+
return c.json({ error: 'Setu wallet not configured' }, 401);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const balance = await fetchSetuBalance({ privateKey });
|
|
74
|
+
if (!balance) {
|
|
75
|
+
return c.json({ error: 'Failed to fetch balance from Setu' }, 502);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return c.json(balance);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
logger.error('Failed to fetch Setu balance', error);
|
|
81
|
+
const errorResponse = serializeError(error);
|
|
82
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
app.get('/v1/setu/wallet', async (c) => {
|
|
87
|
+
try {
|
|
88
|
+
const privateKey = await getSetuPrivateKey();
|
|
89
|
+
if (!privateKey) {
|
|
90
|
+
return c.json(
|
|
91
|
+
{ error: 'Setu wallet not configured', configured: false },
|
|
92
|
+
200,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const publicKey = getPublicKeyFromPrivate(privateKey);
|
|
97
|
+
if (!publicKey) {
|
|
98
|
+
return c.json({ error: 'Invalid private key', configured: false }, 200);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return c.json({
|
|
102
|
+
configured: true,
|
|
103
|
+
publicKey,
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
logger.error('Failed to get Setu wallet info', error);
|
|
107
|
+
const errorResponse = serializeError(error);
|
|
108
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
app.get('/v1/setu/usdc-balance', async (c) => {
|
|
113
|
+
try {
|
|
114
|
+
const privateKey = await getSetuPrivateKey();
|
|
115
|
+
if (!privateKey) {
|
|
116
|
+
return c.json({ error: 'Setu wallet not configured' }, 401);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const network =
|
|
120
|
+
(c.req.query('network') as 'mainnet' | 'devnet') || 'mainnet';
|
|
121
|
+
|
|
122
|
+
const balance = await fetchSolanaUsdcBalance({ privateKey }, network);
|
|
123
|
+
if (!balance) {
|
|
124
|
+
return c.json(
|
|
125
|
+
{ error: 'Failed to fetch USDC balance from Solana' },
|
|
126
|
+
502,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return c.json(balance);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
logger.error('Failed to fetch USDC balance', error);
|
|
133
|
+
const errorResponse = serializeError(error);
|
|
134
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
app.get('/v1/setu/topup/polar/estimate', async (c) => {
|
|
139
|
+
try {
|
|
140
|
+
const amount = c.req.query('amount');
|
|
141
|
+
if (!amount) {
|
|
142
|
+
return c.json({ error: 'Missing amount parameter' }, 400);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const baseUrl = getSetuBaseUrl();
|
|
146
|
+
const response = await fetch(
|
|
147
|
+
`${baseUrl}/v1/topup/polar/estimate?amount=${amount}`,
|
|
148
|
+
{
|
|
149
|
+
method: 'GET',
|
|
150
|
+
headers: { 'Content-Type': 'application/json' },
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const data = await response.json();
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
return c.json(data, response.status as 400 | 500);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return c.json(data);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
logger.error('Failed to get Polar estimate', error);
|
|
162
|
+
const errorResponse = serializeError(error);
|
|
163
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
app.post('/v1/setu/topup/polar', async (c) => {
|
|
168
|
+
try {
|
|
169
|
+
const privateKey = await getSetuPrivateKey();
|
|
170
|
+
if (!privateKey) {
|
|
171
|
+
return c.json({ error: 'Setu wallet not configured' }, 401);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const body = await c.req.json();
|
|
175
|
+
const { amount, successUrl } = body as {
|
|
176
|
+
amount: number;
|
|
177
|
+
successUrl: string;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (!amount || typeof amount !== 'number') {
|
|
181
|
+
return c.json({ error: 'Invalid amount' }, 400);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!successUrl || typeof successUrl !== 'string') {
|
|
185
|
+
return c.json({ error: 'Missing successUrl' }, 400);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const walletHeaders = buildWalletHeaders(privateKey);
|
|
189
|
+
const baseUrl = getSetuBaseUrl();
|
|
190
|
+
|
|
191
|
+
const response = await fetch(`${baseUrl}/v1/topup/polar`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: {
|
|
194
|
+
'Content-Type': 'application/json',
|
|
195
|
+
...walletHeaders,
|
|
196
|
+
},
|
|
197
|
+
body: JSON.stringify({ amount, successUrl }),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const data = await response.json();
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
return c.json(data, response.status as 400 | 500);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return c.json(data);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
logger.error('Failed to create Polar checkout', error);
|
|
208
|
+
const errorResponse = serializeError(error);
|
|
209
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
app.post('/v1/setu/topup/select', async (c) => {
|
|
214
|
+
try {
|
|
215
|
+
const body = await c.req.json();
|
|
216
|
+
const { sessionId, method } = body as {
|
|
217
|
+
sessionId: string;
|
|
218
|
+
method: TopupMethod;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
222
|
+
return c.json({ error: 'Missing sessionId' }, 400);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!method || !['crypto', 'fiat'].includes(method)) {
|
|
226
|
+
return c.json(
|
|
227
|
+
{ error: 'Invalid method, must be "crypto" or "fiat"' },
|
|
228
|
+
400,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const resolved = resolveTopupMethodSelection(sessionId, method);
|
|
233
|
+
if (!resolved) {
|
|
234
|
+
return c.json(
|
|
235
|
+
{ error: 'No pending topup request found for this session' },
|
|
236
|
+
404,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
publish({
|
|
241
|
+
type: 'setu.topup.method_selected',
|
|
242
|
+
sessionId,
|
|
243
|
+
payload: { method },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return c.json({ success: true, method });
|
|
247
|
+
} catch (error) {
|
|
248
|
+
logger.error('Failed to select topup method', error);
|
|
249
|
+
const errorResponse = serializeError(error);
|
|
250
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
app.post('/v1/setu/topup/cancel', async (c) => {
|
|
255
|
+
try {
|
|
256
|
+
const body = await c.req.json();
|
|
257
|
+
const { sessionId, reason } = body as {
|
|
258
|
+
sessionId: string;
|
|
259
|
+
reason?: string;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
263
|
+
return c.json({ error: 'Missing sessionId' }, 400);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const rejected = rejectTopupSelection(
|
|
267
|
+
sessionId,
|
|
268
|
+
reason ?? 'User cancelled',
|
|
269
|
+
);
|
|
270
|
+
if (!rejected) {
|
|
271
|
+
return c.json(
|
|
272
|
+
{ error: 'No pending topup request found for this session' },
|
|
273
|
+
404,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
publish({
|
|
278
|
+
type: 'setu.topup.cancelled',
|
|
279
|
+
sessionId,
|
|
280
|
+
payload: { reason: reason ?? 'User cancelled' },
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return c.json({ success: true });
|
|
284
|
+
} catch (error) {
|
|
285
|
+
logger.error('Failed to cancel topup', error);
|
|
286
|
+
const errorResponse = serializeError(error);
|
|
287
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
app.get('/v1/setu/topup/pending', async (c) => {
|
|
292
|
+
try {
|
|
293
|
+
const sessionId = c.req.query('sessionId');
|
|
294
|
+
if (!sessionId) {
|
|
295
|
+
return c.json({ error: 'Missing sessionId parameter' }, 400);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const pending = getPendingTopup(sessionId);
|
|
299
|
+
if (!pending) {
|
|
300
|
+
return c.json({ hasPending: false });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return c.json({
|
|
304
|
+
hasPending: true,
|
|
305
|
+
sessionId: pending.sessionId,
|
|
306
|
+
messageId: pending.messageId,
|
|
307
|
+
amountUsd: pending.amountUsd,
|
|
308
|
+
currentBalance: pending.currentBalance,
|
|
309
|
+
createdAt: pending.createdAt,
|
|
310
|
+
});
|
|
311
|
+
} catch (error) {
|
|
312
|
+
logger.error('Failed to get pending topup', error);
|
|
313
|
+
const errorResponse = serializeError(error);
|
|
314
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
app.get('/v1/setu/topup/polar/status', async (c) => {
|
|
319
|
+
try {
|
|
320
|
+
const checkoutId = c.req.query('checkoutId');
|
|
321
|
+
if (!checkoutId) {
|
|
322
|
+
return c.json({ error: 'Missing checkoutId parameter' }, 400);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const baseUrl = getSetuBaseUrl();
|
|
326
|
+
const response = await fetch(
|
|
327
|
+
`${baseUrl}/v1/topup/polar/status?checkoutId=${checkoutId}`,
|
|
328
|
+
{
|
|
329
|
+
method: 'GET',
|
|
330
|
+
headers: { 'Content-Type': 'application/json' },
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const data = await response.json();
|
|
335
|
+
if (!response.ok) {
|
|
336
|
+
return c.json(data, response.status as 400 | 500);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return c.json(data);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
logger.error('Failed to check Polar status', error);
|
|
342
|
+
const errorResponse = serializeError(error);
|
|
343
|
+
return c.json(errorResponse, errorResponse.error.status || 500);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { Hono } from 'hono';
|
|
2
|
+
import { streamSSE } from 'hono/streaming';
|
|
3
|
+
import type { TerminalManager } from '@ottocode/sdk';
|
|
4
|
+
import { logger } from '@ottocode/sdk';
|
|
5
|
+
|
|
6
|
+
export function registerTerminalsRoutes(
|
|
7
|
+
app: Hono,
|
|
8
|
+
terminalManager: TerminalManager,
|
|
9
|
+
) {
|
|
10
|
+
app.get('/v1/terminals', async (c) => {
|
|
11
|
+
const terminals = terminalManager.list();
|
|
12
|
+
return c.json({
|
|
13
|
+
terminals: terminals.map((t) => t.toJSON()),
|
|
14
|
+
count: terminals.length,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
app.post('/v1/terminals', async (c) => {
|
|
19
|
+
try {
|
|
20
|
+
logger.debug('POST /v1/terminals called');
|
|
21
|
+
const body = await c.req.json();
|
|
22
|
+
logger.debug('Creating terminal request received', body);
|
|
23
|
+
const { command, args, purpose, cwd, title } = body;
|
|
24
|
+
|
|
25
|
+
if (!command || !purpose) {
|
|
26
|
+
return c.json({ error: 'command and purpose are required' }, 400);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let resolvedCommand = command;
|
|
30
|
+
if (command === 'bash' || command === 'sh' || command === 'shell') {
|
|
31
|
+
resolvedCommand = process.env.SHELL || '/bin/bash';
|
|
32
|
+
}
|
|
33
|
+
const resolvedCwd = cwd || process.cwd();
|
|
34
|
+
|
|
35
|
+
logger.debug('Creating terminal', {
|
|
36
|
+
command: resolvedCommand,
|
|
37
|
+
args,
|
|
38
|
+
purpose,
|
|
39
|
+
cwd: resolvedCwd,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const terminal = terminalManager.create({
|
|
43
|
+
command: resolvedCommand,
|
|
44
|
+
args: args || [],
|
|
45
|
+
purpose,
|
|
46
|
+
cwd: resolvedCwd,
|
|
47
|
+
createdBy: 'user',
|
|
48
|
+
title,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
logger.debug('Terminal created successfully', { id: terminal.id });
|
|
52
|
+
|
|
53
|
+
return c.json({
|
|
54
|
+
terminalId: terminal.id,
|
|
55
|
+
pid: terminal.pid,
|
|
56
|
+
purpose: terminal.purpose,
|
|
57
|
+
command: terminal.command,
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
logger.error('Error creating terminal', error);
|
|
61
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
62
|
+
return c.json({ error: message }, 500);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
app.get('/v1/terminals/:id', async (c) => {
|
|
67
|
+
const id = c.req.param('id');
|
|
68
|
+
const terminal = terminalManager.get(id);
|
|
69
|
+
|
|
70
|
+
if (!terminal) {
|
|
71
|
+
return c.json({ error: 'Terminal not found' }, 404);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return c.json({ terminal: terminal.toJSON() });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
app.get('/v1/terminals/:id/output', async (c) => {
|
|
78
|
+
const id = c.req.param('id');
|
|
79
|
+
logger.debug('SSE client connecting to terminal', { id });
|
|
80
|
+
const terminal = terminalManager.get(id);
|
|
81
|
+
|
|
82
|
+
if (!terminal) {
|
|
83
|
+
logger.debug('SSE terminal not found', { id });
|
|
84
|
+
return c.json({ error: 'Terminal not found' }, 404);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return streamSSE(c, async (stream) => {
|
|
88
|
+
logger.debug('SSE stream started for terminal', { id });
|
|
89
|
+
// Send historical buffer first (unless skipHistory is set)
|
|
90
|
+
const skipHistory = c.req.query('skipHistory') === 'true';
|
|
91
|
+
if (!skipHistory) {
|
|
92
|
+
const history = terminal.read();
|
|
93
|
+
logger.debug('SSE sending terminal history', {
|
|
94
|
+
id,
|
|
95
|
+
lines: history.length,
|
|
96
|
+
});
|
|
97
|
+
for (const line of history) {
|
|
98
|
+
await stream.write(
|
|
99
|
+
`data: ${JSON.stringify({ type: 'data', line })}\n\n`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sendEvent = async (payload: Record<string, unknown>) => {
|
|
105
|
+
try {
|
|
106
|
+
await stream.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
logger.error('SSE error writing event', error, { id });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const onData = (line: string) => {
|
|
113
|
+
void sendEvent({ type: 'data', line });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let resolveStream: (() => void) | null = null;
|
|
117
|
+
let finished = false;
|
|
118
|
+
|
|
119
|
+
function cleanup() {
|
|
120
|
+
terminal.removeDataListener(onData);
|
|
121
|
+
terminal.removeExitListener(onExit);
|
|
122
|
+
c.req.raw.signal.removeEventListener('abort', onAbort);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function finish() {
|
|
126
|
+
if (finished) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
finished = true;
|
|
130
|
+
cleanup();
|
|
131
|
+
resolveStream?.();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function onExit(exitCode: number) {
|
|
135
|
+
try {
|
|
136
|
+
await sendEvent({ type: 'exit', exitCode });
|
|
137
|
+
} finally {
|
|
138
|
+
stream.close();
|
|
139
|
+
finish();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function onAbort() {
|
|
144
|
+
logger.debug('SSE client disconnected from terminal', {
|
|
145
|
+
id: terminal.id,
|
|
146
|
+
});
|
|
147
|
+
stream.close();
|
|
148
|
+
finish();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
terminal.onData(onData);
|
|
152
|
+
terminal.onExit(onExit);
|
|
153
|
+
|
|
154
|
+
c.req.raw.signal.addEventListener('abort', onAbort, { once: true });
|
|
155
|
+
|
|
156
|
+
const waitForClose = new Promise<void>((resolve) => {
|
|
157
|
+
resolveStream = resolve;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (terminal.status === 'exited') {
|
|
161
|
+
void onExit(terminal.exitCode ?? 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await waitForClose;
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
app.post('/v1/terminals/:id/input', async (c) => {
|
|
169
|
+
const id = c.req.param('id');
|
|
170
|
+
const terminal = terminalManager.get(id);
|
|
171
|
+
|
|
172
|
+
if (!terminal) {
|
|
173
|
+
return c.json({ error: 'Terminal not found' }, 404);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const body = await c.req.json();
|
|
178
|
+
const { input } = body;
|
|
179
|
+
|
|
180
|
+
if (!input) {
|
|
181
|
+
return c.json({ error: 'input is required' }, 400);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
terminal.write(input);
|
|
185
|
+
return c.json({ success: true });
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
188
|
+
return c.json({ error: message }, 500);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
app.delete('/v1/terminals/:id', async (c) => {
|
|
193
|
+
const id = c.req.param('id');
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await terminalManager.kill(id);
|
|
197
|
+
return c.json({ success: true });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
200
|
+
return c.json({ error: message }, 500);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
app.post('/v1/terminals/:id/resize', async (c) => {
|
|
205
|
+
const id = c.req.param('id');
|
|
206
|
+
const terminal = terminalManager.get(id);
|
|
207
|
+
|
|
208
|
+
if (!terminal) {
|
|
209
|
+
return c.json({ error: 'Terminal not found' }, 404);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const body = await c.req.json();
|
|
214
|
+
const { cols, rows } = body;
|
|
215
|
+
|
|
216
|
+
if (!cols || !rows || cols < 1 || rows < 1) {
|
|
217
|
+
return c.json({ error: 'valid cols and rows are required' }, 400);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
terminal.resize(cols, rows);
|
|
221
|
+
return c.json({ success: true });
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
+
return c.json({ error: message }, 500);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|