dsh-remote-bridge 0.0.0-stage → 2.2.0

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.
@@ -0,0 +1,691 @@
1
+ package ai.deepseek.dsh.workspace
2
+
3
+ import java.io.Closeable
4
+ import java.util.concurrent.Executors
5
+ import java.util.concurrent.ScheduledFuture
6
+ import java.util.concurrent.TimeUnit
7
+ import java.util.concurrent.atomic.AtomicBoolean
8
+ import kotlinx.serialization.SerialName
9
+ import kotlinx.serialization.Serializable
10
+ import kotlinx.serialization.json.Json
11
+ import kotlinx.serialization.json.JsonElement
12
+ import okhttp3.HttpUrl
13
+ import okhttp3.HttpUrl.Companion.toHttpUrl
14
+ import okhttp3.MediaType.Companion.toMediaType
15
+ import okhttp3.OkHttpClient
16
+ import okhttp3.Request
17
+ import okhttp3.RequestBody.Companion.toRequestBody
18
+ import okhttp3.Response
19
+ import okhttp3.WebSocket
20
+ import okhttp3.WebSocketListener
21
+
22
+ const val API_VERSION: String = "v1"
23
+
24
+ @Serializable
25
+ data class HealthView(val ok: Boolean, val version: String, val pluginVersion: String? = null)
26
+
27
+ @Serializable
28
+ data class RootView(val id: String, val label: String, val createdAt: Long)
29
+
30
+ @Serializable
31
+ data class FileEntry(
32
+ val name: String,
33
+ val path: String,
34
+ val kind: String,
35
+ val size: Long,
36
+ val modifiedAt: Double,
37
+ val writable: Boolean,
38
+ )
39
+
40
+ @Serializable
41
+ data class DirectoryPage(val path: String, val entries: List<FileEntry>, val nextCursor: String? = null)
42
+
43
+ @Serializable
44
+ data class DeviceView(
45
+ val id: String,
46
+ val name: String,
47
+ val scopes: List<String>,
48
+ val rootIds: List<String>,
49
+ )
50
+
51
+ @Serializable
52
+ data class PairingResult(val token: String, val device: DeviceView)
53
+
54
+ @Serializable
55
+ data class TrashEntry(
56
+ val id: String,
57
+ val rootId: String,
58
+ val path: String,
59
+ val kind: String,
60
+ val size: Long,
61
+ val createdAt: Long,
62
+ val status: String,
63
+ )
64
+
65
+ @Serializable
66
+ data class PendingApproval(
67
+ val id: String,
68
+ val sessionId: String,
69
+ val toolName: String,
70
+ val reason: String? = null,
71
+ val detail: String? = null,
72
+ val risk: String,
73
+ val requestedAt: Long,
74
+ )
75
+
76
+ @Serializable
77
+ data class CommandInput(val hint: String)
78
+
79
+ @Serializable
80
+ data class CommandDescriptor(
81
+ val name: String,
82
+ val description: String,
83
+ val input: CommandInput? = null,
84
+ )
85
+
86
+ @Serializable
87
+ data class CommandResult(
88
+ val kind: String,
89
+ val text: String? = null,
90
+ val sourceEventSeq: Int? = null,
91
+ )
92
+
93
+ @Serializable
94
+ data class CommandExecution(val commandId: String, val result: CommandResult)
95
+
96
+ @Serializable
97
+ data class ChatWorkspace(
98
+ val id: String,
99
+ val title: String,
100
+ val rootId: String,
101
+ val path: String,
102
+ val createdAt: String,
103
+ val updatedAt: String,
104
+ )
105
+
106
+ @Serializable
107
+ data class AgentPreset(
108
+ val id: String,
109
+ val name: String,
110
+ val description: String? = null,
111
+ val trust: String,
112
+ val isDefault: Boolean,
113
+ val available: Boolean,
114
+ )
115
+
116
+ @Serializable
117
+ data class ModelSelection(val provider: String, val model: String, val reasoningEffort: String? = null)
118
+
119
+ @Serializable
120
+ data class ModelReasoningEffort(val id: String, val name: String, val description: String? = null)
121
+
122
+ @Serializable
123
+ data class ModelReasoning(
124
+ val efforts: List<ModelReasoningEffort>,
125
+ val defaultEffort: String? = null,
126
+ )
127
+
128
+ @Serializable
129
+ data class ModelView(
130
+ val id: String,
131
+ val name: String,
132
+ val description: String? = null,
133
+ val contextWindow: Long? = null,
134
+ val maxTokens: Long? = null,
135
+ val reasoning: ModelReasoning? = null,
136
+ )
137
+
138
+ @Serializable
139
+ data class ModelProviderGroup(val id: String, val name: String, val models: List<ModelView>)
140
+
141
+ @Serializable
142
+ data class ModelFailure(val provider: String, val message: String)
143
+
144
+ @Serializable
145
+ data class ModelCatalog(
146
+ val groups: List<ModelProviderGroup>,
147
+ val failures: List<ModelFailure> = emptyList(),
148
+ )
149
+
150
+ @Serializable
151
+ data class SessionModels(
152
+ val current: ModelSelection,
153
+ val routable: Boolean,
154
+ val groups: List<ModelProviderGroup>,
155
+ val failures: List<ModelFailure> = emptyList(),
156
+ )
157
+
158
+ @Serializable
159
+ data class ProviderModel(
160
+ val id: String,
161
+ val name: String? = null,
162
+ val contextWindow: Long? = null,
163
+ val maxTokens: Long? = null,
164
+ )
165
+
166
+ @Serializable
167
+ data class ProviderConfig(
168
+ val baseURL: String? = null,
169
+ val api: String? = null,
170
+ val displayName: String? = null,
171
+ val thinking: String? = null,
172
+ val reasoningEffort: String? = null,
173
+ val models: List<ProviderModel> = emptyList(),
174
+ val modelsInherited: Boolean = false,
175
+ )
176
+
177
+ @Serializable
178
+ data class CredentialState(
179
+ val ref: String,
180
+ val configured: Boolean,
181
+ val source: String? = null,
182
+ val writable: Boolean,
183
+ )
184
+
185
+ @Serializable
186
+ data class ProviderView(
187
+ val id: String,
188
+ val displayName: String,
189
+ val active: Boolean,
190
+ val declared: Boolean? = null,
191
+ val configurable: Boolean,
192
+ val configured: Boolean,
193
+ val removable: Boolean,
194
+ val credential: CredentialState,
195
+ val config: ProviderConfig,
196
+ )
197
+
198
+ @Serializable
199
+ data class ProviderSettings(
200
+ val writable: Boolean,
201
+ val revisionByNamespace: Map<String, Int>,
202
+ val customProvider: CustomProviderCapability,
203
+ val providers: List<ProviderView>,
204
+ )
205
+
206
+ @Serializable
207
+ data class CustomProviderCapability(
208
+ val available: Boolean,
209
+ val protocols: List<String>,
210
+ val revision: Int? = null,
211
+ )
212
+
213
+ @Serializable
214
+ data class ProviderPatch(
215
+ val displayName: String? = null,
216
+ val baseURL: String? = null,
217
+ val api: String? = null,
218
+ val apiKey: String? = null,
219
+ val thinking: String? = null,
220
+ val reasoningEffort: String? = null,
221
+ val models: List<ProviderModel>? = null,
222
+ val expectedRevision: Int? = null,
223
+ )
224
+
225
+ @Serializable
226
+ data class CustomProviderCreate(
227
+ val id: String,
228
+ val displayName: String? = null,
229
+ val baseURL: String,
230
+ val api: String,
231
+ val apiKey: String? = null,
232
+ val models: List<ProviderModel>,
233
+ val expectedRevision: Int? = null,
234
+ )
235
+
236
+ @Serializable
237
+ data class WorkspaceEvent(val id: String, val type: String, val time: Long, val data: JsonElement)
238
+
239
+ data class FileContent(val bytes: ByteArray, val etag: String, val contentType: String?)
240
+
241
+ class WorkspaceApiException(
242
+ val status: Int,
243
+ val code: String,
244
+ override val message: String,
245
+ ) : RuntimeException(message)
246
+
247
+ class DshWorkspaceClient(
248
+ apiBase: String,
249
+ private val token: String? = null,
250
+ private val http: OkHttpClient = OkHttpClient(),
251
+ ) {
252
+ private val json = Json { ignoreUnknownKeys = true }
253
+ private val base: HttpUrl = (apiBase.trimEnd('/') + "/").toHttpUrl()
254
+
255
+ fun health(): HealthView = executeJson("healthz", "GET", null, HealthView.serializer(), authenticated = false)
256
+
257
+ fun exchangePairing(code: String, deviceName: String): PairingResult {
258
+ val body = json.encodeToString(PairingRequest.serializer(), PairingRequest(code, deviceName))
259
+ return executeJson("pairings/exchange", "POST", body, PairingResult.serializer(), authenticated = false)
260
+ }
261
+
262
+ fun listRoots(): List<RootView> = executeJson(
263
+ "roots",
264
+ "GET",
265
+ null,
266
+ RootList.serializer(),
267
+ ).items
268
+
269
+ fun currentDevice(): DeviceView = executeJson("devices/self", "GET", null, DeviceView.serializer())
270
+
271
+ fun listEntries(rootId: String, path: String = "", cursor: String? = null): DirectoryPage {
272
+ val url = endpoint("roots/$rootId/entries").newBuilder()
273
+ .addQueryParameter("path", path)
274
+ .apply { if (cursor != null) addQueryParameter("cursor", cursor) }
275
+ .build()
276
+ return executeJson(url, "GET", null, DirectoryPage.serializer())
277
+ }
278
+
279
+ fun readFile(rootId: String, path: String, range: LongRange? = null): FileContent {
280
+ val url = endpoint("roots/$rootId/content").newBuilder().addQueryParameter("path", path).build()
281
+ val builder = request(url)
282
+ if (range != null) builder.header("Range", "bytes=${range.first}-${range.last}")
283
+ http.newCall(builder.get().build()).execute().use { response ->
284
+ ensureSuccess(response)
285
+ return FileContent(
286
+ bytes = response.body?.bytes() ?: ByteArray(0),
287
+ etag = response.header("ETag") ?: "",
288
+ contentType = response.header("Content-Type"),
289
+ )
290
+ }
291
+ }
292
+
293
+ fun writeFile(rootId: String, path: String, bytes: ByteArray, etag: String? = null): String {
294
+ val url = endpoint("roots/$rootId/content").newBuilder().addQueryParameter("path", path).build()
295
+ val builder = request(url)
296
+ .put(bytes.toRequestBody("application/octet-stream".toMediaType()))
297
+ if (etag == null) builder.header("If-None-Match", "*") else builder.header("If-Match", etag)
298
+ http.newCall(builder.build()).execute().use { response ->
299
+ ensureSuccess(response)
300
+ return response.header("ETag") ?: ""
301
+ }
302
+ }
303
+
304
+ fun createEntry(rootId: String, path: String, kind: String) {
305
+ val body = json.encodeToString(CreateEntryRequest.serializer(), CreateEntryRequest(path, kind))
306
+ executeUnit("roots/$rootId/entries", "POST", body)
307
+ }
308
+
309
+ fun moveEntry(rootId: String, path: String, destinationPath: String) {
310
+ val body = json.encodeToString(MoveEntryRequest.serializer(), MoveEntryRequest(path, destinationPath))
311
+ executeUnit("roots/$rootId/entries", "PATCH", body)
312
+ }
313
+
314
+ fun trashEntry(rootId: String, path: String) {
315
+ val url = endpoint("roots/$rootId/entries").newBuilder().addQueryParameter("path", path).build()
316
+ executeUnit(url, "DELETE", null)
317
+ }
318
+
319
+ fun listTrash(): List<TrashEntry> = executeJson(
320
+ "trash",
321
+ "GET",
322
+ null,
323
+ TrashList.serializer(),
324
+ ).items
325
+
326
+ fun restoreTrash(trashId: String) {
327
+ executeUnit("trash/$trashId/restore", "POST", null)
328
+ }
329
+
330
+ fun listSessions(): JsonElement = executeJsonElement("chat/sessions", "GET", null)
331
+
332
+ fun listChatWorkspaces(): List<ChatWorkspace> = executeJson(
333
+ "chat/workspaces",
334
+ "GET",
335
+ null,
336
+ ChatWorkspaceList.serializer(),
337
+ ).items
338
+
339
+ fun listAgentPresets(): List<AgentPreset> = executeJson(
340
+ "chat/agent-presets",
341
+ "GET",
342
+ null,
343
+ AgentPresetList.serializer(),
344
+ ).items
345
+
346
+ fun selectSessionAgentPreset(sessionId: String, agentPreset: String): String {
347
+ val body = json.encodeToString(AgentPresetSelection.serializer(), AgentPresetSelection(agentPreset))
348
+ return executeJson(
349
+ "chat/sessions/$sessionId/agent-preset",
350
+ "PUT",
351
+ body,
352
+ SelectedAgentPresetEnvelope.serializer(),
353
+ ).agentPreset
354
+ }
355
+
356
+ fun listSessionModels(sessionId: String): SessionModels = executeJson(
357
+ "chat/sessions/$sessionId/models",
358
+ "GET",
359
+ null,
360
+ SessionModels.serializer(),
361
+ )
362
+
363
+ fun selectSessionModel(sessionId: String, selection: ModelSelection): ModelSelection {
364
+ val body = json.encodeToString(ModelSelection.serializer(), selection)
365
+ return executeJson(
366
+ "chat/sessions/$sessionId/model",
367
+ "PUT",
368
+ body,
369
+ SelectedModelEnvelope.serializer(),
370
+ ).selected
371
+ }
372
+
373
+ fun listProviderSettings(): ProviderSettings = executeJson(
374
+ "settings/providers",
375
+ "GET",
376
+ null,
377
+ ProviderSettings.serializer(),
378
+ )
379
+
380
+ fun listModelCatalog(): ModelCatalog = executeJson(
381
+ "settings/models",
382
+ "GET",
383
+ null,
384
+ ModelCatalog.serializer(),
385
+ )
386
+
387
+ fun updateProvider(providerId: String, patch: ProviderPatch): ProviderSettings {
388
+ val body = json.encodeToString(ProviderPatch.serializer(), patch)
389
+ return executeJson("settings/providers/$providerId", "PATCH", body, ProviderSettings.serializer())
390
+ }
391
+
392
+ fun createCustomProvider(provider: CustomProviderCreate): ProviderSettings {
393
+ val body = json.encodeToString(CustomProviderCreate.serializer(), provider)
394
+ return executeJson("settings/providers", "POST", body, ProviderSettings.serializer())
395
+ }
396
+
397
+ fun discoverProviderModels(
398
+ providerId: String,
399
+ baseURL: String? = null,
400
+ api: String? = null,
401
+ apiKey: String? = null,
402
+ ): List<ProviderModel> {
403
+ val body = json.encodeToString(
404
+ DiscoverModelsRequest.serializer(),
405
+ DiscoverModelsRequest(baseURL, api, apiKey),
406
+ )
407
+ return executeJson(
408
+ "settings/providers/$providerId/discover",
409
+ "POST",
410
+ body,
411
+ DiscoveredModelsEnvelope.serializer(),
412
+ ).models
413
+ }
414
+
415
+ fun createSessionInWorkspace(
416
+ workspaceId: String,
417
+ clientRequestId: String,
418
+ agentPreset: String? = null,
419
+ ): JsonElement {
420
+ val body = json.encodeToString(
421
+ WorkspaceSessionRequest.serializer(),
422
+ WorkspaceSessionRequest(workspaceId, agentPreset, clientRequestId),
423
+ )
424
+ return executeJsonElement("chat/sessions", "POST", body)
425
+ }
426
+
427
+ fun createChatWorkspace(rootId: String, path: String): ChatWorkspace {
428
+ val body = json.encodeToString(CreateWorkspaceRequest.serializer(), CreateWorkspaceRequest(rootId, path))
429
+ return executeJson(
430
+ "chat/workspaces",
431
+ "POST",
432
+ body,
433
+ CreatedWorkspaceEnvelope.serializer(),
434
+ ).workspace
435
+ }
436
+
437
+ fun renameChatWorkspace(workspaceId: String, title: String): ChatWorkspace {
438
+ val body = json.encodeToString(RenameRequest.serializer(), RenameRequest(title))
439
+ return executeJson(
440
+ "chat/workspaces/$workspaceId",
441
+ "PATCH",
442
+ body,
443
+ CreatedWorkspaceEnvelope.serializer(),
444
+ ).workspace
445
+ }
446
+
447
+ fun deleteChatWorkspace(workspaceId: String) {
448
+ executeUnit("chat/workspaces/$workspaceId", "DELETE", null)
449
+ }
450
+
451
+ fun createSession(rootId: String, path: String, clientRequestId: String): JsonElement {
452
+ val body = json.encodeToString(
453
+ CreateSessionRequest.serializer(),
454
+ CreateSessionRequest(rootId, path, clientRequestId),
455
+ )
456
+ return executeJsonElement("chat/sessions", "POST", body)
457
+ }
458
+
459
+ fun listMessages(sessionId: String): JsonElement = executeJsonElement("chat/sessions/$sessionId/messages", "GET", null)
460
+
461
+ fun renameSession(sessionId: String, title: String) {
462
+ val body = json.encodeToString(RenameRequest.serializer(), RenameRequest(title))
463
+ executeUnit("chat/sessions/$sessionId", "PATCH", body)
464
+ }
465
+
466
+ fun forkSession(sessionId: String, atSeq: Int? = null): String {
467
+ val body = json.encodeToString(ForkSessionRequest.serializer(), ForkSessionRequest(atSeq))
468
+ return executeJson(
469
+ "chat/sessions/$sessionId/fork",
470
+ "POST",
471
+ body,
472
+ ForkedSessionEnvelope.serializer(),
473
+ ).sessionId
474
+ }
475
+
476
+ fun archiveSession(sessionId: String) {
477
+ executeUnit("chat/sessions/$sessionId/archive", "POST", null)
478
+ }
479
+
480
+ fun listPendingApprovals(sessionId: String): List<PendingApproval> = executeJson(
481
+ "chat/sessions/$sessionId/approvals",
482
+ "GET",
483
+ null,
484
+ PendingApprovalList.serializer(),
485
+ ).items
486
+
487
+ fun decideApproval(sessionId: String, approvalId: String, allowOnce: Boolean) {
488
+ val body = json.encodeToString(
489
+ ApprovalDecision.serializer(),
490
+ ApprovalDecision(if (allowOnce) "allowed-once" else "rejected"),
491
+ )
492
+ executeUnit("chat/sessions/$sessionId/approvals/$approvalId/decision", "POST", body)
493
+ }
494
+
495
+ fun listSessionCommands(sessionId: String): List<CommandDescriptor> = executeJson(
496
+ "chat/sessions/$sessionId/commands",
497
+ "GET",
498
+ null,
499
+ CommandDescriptorList.serializer(),
500
+ ).items
501
+
502
+ fun executeSessionCommand(sessionId: String, line: String): CommandExecution {
503
+ val body = json.encodeToString(CommandRequest.serializer(), CommandRequest(line))
504
+ return executeJson(
505
+ "chat/sessions/$sessionId/commands",
506
+ "POST",
507
+ body,
508
+ CommandExecutionEnvelope.serializer(),
509
+ ).execution
510
+ }
511
+
512
+ fun sendMessage(sessionId: String, text: String, clientRequestId: String, steer: Boolean = false): JsonElement {
513
+ val body = json.encodeToString(
514
+ SendMessageRequest.serializer(),
515
+ SendMessageRequest(text, if (steer) "steer" else "queue", clientRequestId),
516
+ )
517
+ return executeJsonElement("chat/sessions/$sessionId/messages", "POST", body)
518
+ }
519
+
520
+ fun cancel(sessionId: String) {
521
+ executeUnit("chat/runs/$sessionId/cancel", "POST", null)
522
+ }
523
+
524
+ fun events(listener: WorkspaceEventListener): EventSubscription {
525
+ require(token != null) { "A paired device token is required for events." }
526
+ return EventSubscription(http, webSocketUrl(), token, json, listener)
527
+ }
528
+
529
+ private fun webSocketUrl(): HttpUrl = base.newBuilder().addPathSegment("events").build()
530
+
531
+ private fun endpoint(relative: String): HttpUrl = base.resolve(relative)
532
+ ?: throw IllegalArgumentException("Invalid API path: $relative")
533
+
534
+ private fun request(url: HttpUrl, authenticated: Boolean = true): Request.Builder {
535
+ val builder = Request.Builder().url(url).header("Accept", "application/json")
536
+ if (authenticated) builder.header("Authorization", "Bearer ${requireNotNull(token) { "A device token is required." }}")
537
+ return builder
538
+ }
539
+
540
+ private fun <T> executeJson(
541
+ relative: String,
542
+ method: String,
543
+ body: String?,
544
+ serializer: kotlinx.serialization.KSerializer<T>,
545
+ authenticated: Boolean = true,
546
+ ): T = executeJson(endpoint(relative), method, body, serializer, authenticated)
547
+
548
+ private fun <T> executeJson(
549
+ url: HttpUrl,
550
+ method: String,
551
+ body: String?,
552
+ serializer: kotlinx.serialization.KSerializer<T>,
553
+ authenticated: Boolean = true,
554
+ ): T {
555
+ val response = execute(url, method, body, authenticated)
556
+ response.use {
557
+ ensureSuccess(it)
558
+ return json.decodeFromString(serializer, it.body?.string() ?: "{}")
559
+ }
560
+ }
561
+
562
+ private fun executeJsonElement(relative: String, method: String, body: String?): JsonElement {
563
+ val response = execute(endpoint(relative), method, body, true)
564
+ response.use {
565
+ ensureSuccess(it)
566
+ return json.parseToJsonElement(it.body?.string() ?: "{}")
567
+ }
568
+ }
569
+
570
+ private fun executeUnit(relative: String, method: String, body: String?) = executeUnit(endpoint(relative), method, body)
571
+
572
+ private fun executeUnit(url: HttpUrl, method: String, body: String?) {
573
+ execute(url, method, body, true).use { ensureSuccess(it) }
574
+ }
575
+
576
+ private fun execute(url: HttpUrl, method: String, body: String?, authenticated: Boolean): Response {
577
+ val requestBody = body?.toRequestBody("application/json".toMediaType())
578
+ return http.newCall(request(url, authenticated).method(method, requestBody).build()).execute()
579
+ }
580
+
581
+ private fun ensureSuccess(response: Response) {
582
+ if (response.isSuccessful) return
583
+ val raw = response.body?.string().orEmpty()
584
+ val parsed = runCatching { json.decodeFromString(ErrorEnvelope.serializer(), raw) }.getOrNull()
585
+ throw WorkspaceApiException(
586
+ response.code,
587
+ parsed?.error?.code ?: "HTTP_ERROR",
588
+ parsed?.error?.message ?: "HTTP ${response.code}",
589
+ )
590
+ }
591
+ }
592
+
593
+ interface WorkspaceEventListener {
594
+ fun onEvent(event: WorkspaceEvent)
595
+ fun onConnectionChanged(connected: Boolean) {}
596
+ fun onPermanentFailure(error: Throwable) {}
597
+ }
598
+
599
+ class EventSubscription internal constructor(
600
+ private val http: OkHttpClient,
601
+ private val url: HttpUrl,
602
+ private val token: String,
603
+ private val json: Json,
604
+ private val listener: WorkspaceEventListener,
605
+ ) : Closeable {
606
+ private val closed = AtomicBoolean(false)
607
+ private val scheduler = Executors.newSingleThreadScheduledExecutor()
608
+ private var socket: WebSocket? = null
609
+ private var retry: ScheduledFuture<*>? = null
610
+ private var attempts = 0
611
+
612
+ init { connect() }
613
+
614
+ private fun connect() {
615
+ if (closed.get()) return
616
+ val request = Request.Builder().url(url).header("Authorization", "Bearer $token").build()
617
+ socket = http.newWebSocket(request, object : WebSocketListener() {
618
+ override fun onOpen(webSocket: WebSocket, response: Response) {
619
+ attempts = 0
620
+ listener.onConnectionChanged(true)
621
+ }
622
+
623
+ override fun onMessage(webSocket: WebSocket, text: String) {
624
+ runCatching { json.decodeFromString(WorkspaceEvent.serializer(), text) }
625
+ .onSuccess(listener::onEvent)
626
+ .onFailure(listener::onPermanentFailure)
627
+ }
628
+
629
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
630
+ listener.onConnectionChanged(false)
631
+ if (code == 4001) listener.onPermanentFailure(IllegalStateException("Device token was revoked."))
632
+ else reconnect()
633
+ }
634
+
635
+ override fun onFailure(webSocket: WebSocket, error: Throwable, response: Response?) {
636
+ listener.onConnectionChanged(false)
637
+ if (response?.code == 401 || response?.code == 403) listener.onPermanentFailure(error) else reconnect()
638
+ }
639
+ })
640
+ }
641
+
642
+ private fun reconnect() {
643
+ if (closed.get()) return
644
+ val delaySeconds = minOf(30L, 1L shl minOf(attempts++, 5))
645
+ retry?.cancel(false)
646
+ retry = scheduler.schedule(::connect, delaySeconds, TimeUnit.SECONDS)
647
+ }
648
+
649
+ override fun close() {
650
+ if (!closed.compareAndSet(false, true)) return
651
+ retry?.cancel(false)
652
+ socket?.close(1000, "Client closed")
653
+ scheduler.shutdownNow()
654
+ }
655
+ }
656
+
657
+ @Serializable private data class RootList(val items: List<RootView>)
658
+ @Serializable private data class TrashList(val items: List<TrashEntry>)
659
+ @Serializable private data class PendingApprovalList(val items: List<PendingApproval>)
660
+ @Serializable private data class ApprovalDecision(val outcome: String)
661
+ @Serializable private data class CommandDescriptorList(val items: List<CommandDescriptor>)
662
+ @Serializable private data class CommandRequest(val line: String)
663
+ @Serializable private data class CommandExecutionEnvelope(val execution: CommandExecution)
664
+ @Serializable private data class ChatWorkspaceList(val items: List<ChatWorkspace>)
665
+ @Serializable private data class CreatedWorkspaceEnvelope(val workspace: ChatWorkspace)
666
+ @Serializable private data class RenameRequest(val title: String)
667
+ @Serializable private data class ForkSessionRequest(val atSeq: Int? = null)
668
+ @Serializable private data class ForkedSessionEnvelope(val sessionId: String)
669
+ @Serializable private data class AgentPresetList(val items: List<AgentPreset>)
670
+ @Serializable private data class AgentPresetSelection(val agentPreset: String)
671
+ @Serializable private data class SelectedAgentPresetEnvelope(val agentPreset: String)
672
+ @Serializable private data class SelectedModelEnvelope(val selected: ModelSelection)
673
+ @Serializable private data class DiscoveredModelsEnvelope(val models: List<ProviderModel>)
674
+ @Serializable private data class DiscoverModelsRequest(
675
+ val baseURL: String? = null,
676
+ val api: String? = null,
677
+ val apiKey: String? = null,
678
+ )
679
+ @Serializable private data class PairingRequest(val code: String, val deviceName: String)
680
+ @Serializable private data class CreateEntryRequest(val path: String, val kind: String)
681
+ @Serializable private data class MoveEntryRequest(val path: String, val destinationPath: String)
682
+ @Serializable private data class CreateWorkspaceRequest(val rootId: String, val path: String)
683
+ @Serializable private data class CreateSessionRequest(val rootId: String, val path: String, val clientRequestId: String)
684
+ @Serializable private data class WorkspaceSessionRequest(
685
+ val workspaceId: String,
686
+ val agentPreset: String? = null,
687
+ val clientRequestId: String,
688
+ )
689
+ @Serializable private data class SendMessageRequest(val text: String, val mode: String, val clientRequestId: String)
690
+ @Serializable private data class ErrorEnvelope(val error: ApiErrorBody)
691
+ @Serializable private data class ApiErrorBody(val code: String, val message: String, val requestId: String)