@mirrowel/opencode-openshare 0.0.0-stage → 0.1.1

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -2
  3. package/ci/README.md +56 -0
  4. package/ci/openshare.config.json +10 -0
  5. package/ci/openshare.js +2270 -0
  6. package/dist/commands.d.ts +7 -0
  7. package/dist/commands.js +440 -0
  8. package/dist/config.d.ts +138 -0
  9. package/dist/config.js +524 -0
  10. package/dist/core.d.ts +117 -0
  11. package/dist/core.js +1944 -0
  12. package/dist/dialogs.d.ts +94 -0
  13. package/dist/dialogs.js +725 -0
  14. package/dist/server.d.ts +64 -0
  15. package/dist/server.js +30 -0
  16. package/dist/settings.d.ts +24 -0
  17. package/dist/settings.js +1226 -0
  18. package/dist/share-flow.d.ts +287 -0
  19. package/dist/share-flow.js +516 -0
  20. package/dist/sidebar.d.ts +19 -0
  21. package/dist/sidebar.js +144 -0
  22. package/dist/tui-host.d.ts +158 -0
  23. package/dist/tui-host.js +6 -0
  24. package/dist/tui.d.ts +17 -0
  25. package/dist/tui.js +4719 -0
  26. package/dist/ui-prefs.d.ts +16 -0
  27. package/dist/ui-prefs.js +27 -0
  28. package/dist/v2-server.d.ts +81 -0
  29. package/dist/v2-server.js +402 -0
  30. package/dist/v2-tui.d.ts +24 -0
  31. package/dist/v2-tui.js +313 -0
  32. package/dist/v2-types.d.ts +134 -0
  33. package/dist/v2-types.js +8 -0
  34. package/openshare.config.json +16 -0
  35. package/package.json +98 -4
  36. package/src/banner.txt +25 -0
  37. package/src/commands.ts +452 -0
  38. package/src/config.ts +524 -0
  39. package/src/core.ts +1997 -0
  40. package/src/dialogs.tsx +971 -0
  41. package/src/server.ts +47 -0
  42. package/src/settings.ts +1239 -0
  43. package/src/share-flow.ts +551 -0
  44. package/src/sidebar.tsx +187 -0
  45. package/src/tui-host.ts +134 -0
  46. package/src/tui.tsx +318 -0
  47. package/src/ui-prefs.ts +36 -0
  48. package/src/v2-server.ts +438 -0
  49. package/src/v2-tui.ts +315 -0
  50. package/src/v2-types.ts +102 -0
  51. package/tsconfig.json +18 -0
@@ -0,0 +1,438 @@
1
+ // Open(code)Share — OpenCode v2 server setup.
2
+ //
3
+ // The v2 host validates `default` as `{ id, setup }` and calls setup with
4
+ // the v2 plugin context (packages/plugin/src/promise). The v2 world is
5
+ // shaped differently from v1 in three places this adapter bridges:
6
+ //
7
+ // 1. Events: v2 publishes fine-grained domain events
8
+ // (session.metadata.updated, session.text.started, session.tool.*,
9
+ // session.step.*) — not v1's coarse session/message/part updates.
10
+ // Mapping strategy: structural events (created/deleted/metadata/
11
+ // status) pass into the engine directly; content events trigger a
12
+ // debounced re-sync that pulls the fresh message list through the
13
+ // client. The engine's keyed queue makes re-syncs idempotent, and
14
+ // correctness never depends on decoding every fine-grained shape.
15
+ //
16
+ // 2. Client: the v2 client speaks /api/session with {sessionID} inputs,
17
+ // unwrapped outputs and cursor pagination. The adapter below presents
18
+ // the v1-shaped ClientApi the engine already speaks, including the
19
+ // v2-message -> v1 message+parts conversion (the worker/viewer
20
+ // contract is the v1 Data[] format).
21
+ //
22
+ // 3. Share mode: v2 auto-discovers the package's ./tui entrypoint, so
23
+ // presence detection cannot scan tui.json. The adapter applies the
24
+ // v2-native rule instead: when the plugin's own package declares the
25
+ // tui entrypoint (oc-plugin), a human drives sharing -> manual; the
26
+ // single-file build (no package) stays all-mode. Explicit shareMode
27
+ // config still wins.
28
+
29
+ import { existsSync, readFileSync } from "node:fs"
30
+ import { join } from "node:path"
31
+ import { METADATA_KEY, moduleDir, packageRoot } from "./config.js"
32
+ import { createEngine, type ClientApi, type Engine, type MessageInfo, type PartInfo } from "./core.js"
33
+
34
+ type V2Event = {
35
+ type: string
36
+ data?: Record<string, unknown>
37
+ [key: string]: unknown
38
+ }
39
+
40
+ type V2Message = {
41
+ id: string
42
+ type: string
43
+ time?: { created?: number; completed?: number; streamed?: number }
44
+ text?: string
45
+ agent?: string
46
+ model?: { providerID: string; id?: string; modelID?: string }
47
+ content?: Array<Record<string, unknown>>
48
+ error?: { message?: string } | null
49
+ metadata?: Record<string, unknown>
50
+ }
51
+
52
+ type V2SessionClient = {
53
+ get(input: { sessionID: string }): Promise<unknown>
54
+ update(input: { sessionID: string; title?: string; metadata?: Record<string, unknown> }): Promise<unknown>
55
+ list(input?: { directory?: string; parentID?: string; cursor?: string; limit?: number }): Promise<unknown>
56
+ diff(input: { sessionID: string }): Promise<unknown>
57
+ remove?(input: { sessionID: string }): Promise<unknown>
58
+ }
59
+
60
+ type V2MessageClient = {
61
+ list(input: { sessionID: string; limit?: number; cursor?: string }): Promise<unknown>
62
+ }
63
+
64
+ type V2Context = {
65
+ event?: {
66
+ subscribe(options?: { signal?: AbortSignal }): AsyncIterable<V2Event>
67
+ }
68
+ client?: {
69
+ session?: V2SessionClient
70
+ message?: V2MessageClient
71
+ }
72
+ options?: Record<string, unknown>
73
+ app?: { name?: string; version?: string; channel?: string }
74
+ signal?: AbortSignal
75
+ }
76
+
77
+ // --- v2 message -> v1 items ---------------------------------------------------
78
+
79
+ function toolContentText(content: unknown): string {
80
+ if (typeof content === "string") return content
81
+ if (Array.isArray(content)) {
82
+ return content
83
+ .map((item) => {
84
+ if (typeof item === "string") return item
85
+ if (item && typeof item === "object") {
86
+ const record = item as Record<string, unknown>
87
+ if (typeof record.text === "string") return record.text
88
+ }
89
+ return ""
90
+ })
91
+ .join("")
92
+ }
93
+ return ""
94
+ }
95
+
96
+ function convertToolState(state: unknown): Record<string, unknown> {
97
+ if (!state || typeof state !== "object") return { status: "pending" }
98
+ const record = state as Record<string, unknown>
99
+ const status = String(record.status ?? "pending")
100
+ const mapped: Record<string, unknown> = {}
101
+ if (status === "streaming" || status === "running") mapped.status = "running"
102
+ else if (status === "completed") mapped.status = "completed"
103
+ else if (status === "failed" || status === "error") mapped.status = "error"
104
+ else mapped.status = "pending"
105
+ if (record.input !== undefined) mapped.input = record.input
106
+ if (record.output !== undefined) mapped.output = toolContentText(record.output)
107
+ if (record.error !== undefined) mapped.error = record.error
108
+ if (record.time !== undefined) mapped.time = record.time
109
+ return mapped
110
+ }
111
+
112
+ /** v2 flat message -> { info, parts } in the v1 shapes the viewer speaks. */
113
+ export function convertV2Message(message: V2Message, sessionID: string): { info: MessageInfo; parts: PartInfo[] } | undefined {
114
+ if (!message || typeof message.id !== "string") return undefined
115
+ const base = {
116
+ id: message.id,
117
+ sessionID,
118
+ }
119
+ if (message.type === "user") {
120
+ return {
121
+ info: {
122
+ ...base,
123
+ role: "user",
124
+ time: { created: message.time?.created ?? 0 },
125
+ text: message.text ?? "",
126
+ },
127
+ parts: [],
128
+ }
129
+ }
130
+ if (message.type === "assistant") {
131
+ const parts: PartInfo[] = []
132
+ const content = Array.isArray(message.content) ? message.content : []
133
+ content.forEach((block, index) => {
134
+ const record = block as Record<string, unknown>
135
+ const kind = String(record.type ?? "")
136
+ if (kind === "text") {
137
+ parts.push({
138
+ id: `${message.id}_t${index}`,
139
+ messageID: message.id,
140
+ sessionID,
141
+ type: "text",
142
+ text: String(record.text ?? ""),
143
+ synthetic: false,
144
+ time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
145
+ })
146
+ } else if (kind === "reasoning") {
147
+ parts.push({
148
+ id: `${message.id}_r${index}`,
149
+ messageID: message.id,
150
+ sessionID,
151
+ type: "reasoning",
152
+ text: String(record.text ?? ""),
153
+ synthetic: false,
154
+ time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 },
155
+ })
156
+ } else if (kind === "tool") {
157
+ parts.push({
158
+ id: String(record.id ?? `${message.id}_tool${index}`),
159
+ messageID: message.id,
160
+ sessionID,
161
+ type: "tool",
162
+ tool: String(record.name ?? "unknown"),
163
+ state: convertToolState(record.state),
164
+ synthetic: false,
165
+ })
166
+ }
167
+ })
168
+ return {
169
+ info: {
170
+ ...base,
171
+ role: "assistant",
172
+ agent: message.agent,
173
+ model: message.model
174
+ ? { providerID: message.model.providerID, modelID: message.model.modelID ?? message.model.id }
175
+ : undefined,
176
+ time: { created: message.time?.created ?? 0, completed: message.time?.completed ?? 0 },
177
+ },
178
+ parts,
179
+ }
180
+ }
181
+ return undefined
182
+ }
183
+
184
+ // --- v2 client -> engine ClientApi ---------------------------------------------
185
+
186
+ function adaptSessionClient(session: V2SessionClient | undefined, message: V2MessageClient | undefined, directory: string | undefined): ClientApi | undefined {
187
+ if (!session) return undefined
188
+ const loc = () => (directory ? { directory } : undefined)
189
+
190
+ const listAll = async () => {
191
+ const out: unknown[] = []
192
+ let cursor: string | undefined
193
+ for (let page = 0; page < 50; page++) {
194
+ const res = (await session.list({ ...loc(), cursor, limit: 100 })) as { data?: unknown[]; cursor?: { next?: string | null } } | undefined
195
+ const data = res?.data
196
+ if (Array.isArray(data)) out.push(...data)
197
+ const next = res?.cursor?.next ?? undefined
198
+ if (!next || !Array.isArray(data) || data.length === 0) break
199
+ cursor = next
200
+ }
201
+ return out
202
+ }
203
+
204
+ const listMessages = async (sessionID: string) => {
205
+ if (!message) return []
206
+ const out: Array<{ info: MessageInfo; parts: PartInfo[] }> = []
207
+ let cursor: string | undefined
208
+ for (let page = 0; page < 50; page++) {
209
+ const res = (await message.list({ sessionID, limit: 100, cursor })) as
210
+ | { data?: V2Message[]; cursor?: { next?: string | null } }
211
+ | undefined
212
+ const data = res?.data
213
+ if (!Array.isArray(data)) break
214
+ for (const raw of data) {
215
+ const converted = convertV2Message(raw, sessionID)
216
+ if (converted) out.push(converted)
217
+ }
218
+ const next = res?.cursor?.next ?? undefined
219
+ if (!next || data.length === 0) break
220
+ cursor = next
221
+ }
222
+ return out
223
+ }
224
+
225
+ return {
226
+ session: {
227
+ async get(input) {
228
+ const res = await session.get({ sessionID: input.path.id })
229
+ return { data: res }
230
+ },
231
+ async update(input) {
232
+ const body = input.body as { title?: string; metadata?: Record<string, unknown> }
233
+ const res = await session.update({ sessionID: input.path.id, title: body.title, metadata: body.metadata })
234
+ return { data: res }
235
+ },
236
+ async list() {
237
+ return { data: await listAll() }
238
+ },
239
+ async messages(input) {
240
+ return { data: await listMessages(input.path.id) }
241
+ },
242
+ async diff(input) {
243
+ const res = await session.diff({ sessionID: input.path.id })
244
+ return { data: res }
245
+ },
246
+ },
247
+ }
248
+ }
249
+
250
+ // --- event classification --------------------------------------------------------
251
+
252
+ const SESSION_ITEM_EVENTS = new Set([
253
+ "session.created",
254
+ "session.renamed",
255
+ "session.metadata.updated",
256
+ "session.agent.selected",
257
+ "session.model.selected",
258
+ "session.moved",
259
+ "session.permissions",
260
+ "session.compaction.started",
261
+ "session.compaction.ended",
262
+ ])
263
+
264
+ const CONTENT_EVENTS_PREFIXES = [
265
+ "session.text.",
266
+ "session.reasoning.",
267
+ "session.tool.",
268
+ "session.message.",
269
+ "session.step.streamed",
270
+ ]
271
+
272
+ const BUSY_EVENTS = new Set(["session.step.started", "session.execution.started"])
273
+ const IDLE_EVENTS = new Set(["session.step.ended", "session.execution.succeeded", "session.execution.interrupted"])
274
+ const ERROR_EVENTS = new Set(["session.step.failed", "session.tool.failed", "session.retry.scheduled"])
275
+
276
+ function eventSessionID(event: V2Event): string | undefined {
277
+ const data = event.data
278
+ if (data && typeof data === "object" && typeof (data as { sessionID?: unknown }).sessionID === "string") {
279
+ return (data as { sessionID: string }).sessionID
280
+ }
281
+ return undefined
282
+ }
283
+
284
+ // --- the setup ---------------------------------------------------------------------
285
+
286
+ function resolveV2ShareMode(options: Record<string, unknown> | undefined): "all" | "manual" | undefined {
287
+ const explicit = options?.["shareMode"] ?? process.env.OPENSHARE_SHARE_MODE
288
+ if (explicit === "all" || explicit === "manual") return explicit
289
+ // v2-native presence: the v2 host auto-loads ./tui for packages that
290
+ // declare it. The single-file build ships no package.json next to it.
291
+ try {
292
+ const root = packageRoot(moduleDir())
293
+ const pkgPath = join(root, "package.json")
294
+ if (existsSync(pkgPath)) {
295
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
296
+ const declared = pkg["oc-plugin"]
297
+ if (Array.isArray(declared) && declared.includes("tui")) return "manual"
298
+ }
299
+ } catch {}
300
+ return undefined
301
+ }
302
+
303
+ export function createV2ServerSetup() {
304
+ return async (context: V2Context): Promise<(() => void) | void> => {
305
+ if (!context?.event?.subscribe || !context.client?.session) {
306
+ // v1 directory loads call BOTH `server` and `setup` on the default
307
+ // export; the v1 input arrives here and trips this guard. That is
308
+ // expected on v1 — stay inert and quiet (debug file only).
309
+ if (process.env.OPENSHARE_DEBUG_FILE) {
310
+ try {
311
+ console.error(`[${METADATA_KEY}] v2 setup not applicable on this host — sharing handled by the server entry`)
312
+ } catch {}
313
+ }
314
+ return () => {}
315
+ }
316
+
317
+ const directory = (context as { directory?: string }).directory
318
+ const client = adaptSessionClient(context.client.session, context.client.message, directory)
319
+ const modeOverride = resolveV2ShareMode(context.options)
320
+ const engine: Engine = createEngine({
321
+ client,
322
+ pluginDir: moduleDir(),
323
+ directory,
324
+ options: modeOverride ? { ...context.options, shareMode: modeOverride } : context.options,
325
+ })
326
+
327
+ if (!engine.hooks.event) {
328
+ return () => {}
329
+ }
330
+
331
+ // debounced content re-sync per session: fetch info + messages through
332
+ // the client adapter and feed them into the engine as the v1 events it
333
+ // understands (session.updated / message.updated / message.part.updated).
334
+ // The engine's keyed queue makes repeated re-syncs idempotent.
335
+ const pendingRefresh = new Map<string, ReturnType<typeof setTimeout>>()
336
+ const refresh = (sessionID: string) => {
337
+ let timer = pendingRefresh.get(sessionID)
338
+ if (timer) clearTimeout(timer)
339
+ timer = setTimeout(() => {
340
+ pendingRefresh.delete(sessionID)
341
+ void (async () => {
342
+ try {
343
+ const info = await (client as NonNullable<typeof client>).session.get({ path: { id: sessionID } })
344
+ await engine.hooks.event({
345
+ event: { type: "session.updated", properties: { sessionID, info: (info as { data?: unknown }).data ?? info } },
346
+ })
347
+ const messages = await (client as NonNullable<typeof client>).session.messages?.({ path: { id: sessionID } })
348
+ const list = ((messages as { data?: unknown })?.data ?? []) as Array<{ info: MessageInfo; parts: PartInfo[] }>
349
+ for (const message of list) {
350
+ await engine.hooks.event({
351
+ event: { type: "message.updated", properties: { sessionID, info: message.info } },
352
+ })
353
+ for (const part of message.parts ?? []) {
354
+ await engine.hooks.event({
355
+ event: { type: "message.part.updated", properties: { sessionID, part } },
356
+ })
357
+ }
358
+ }
359
+ } catch {
360
+ /* refresh is best-effort; the next event retries */
361
+ }
362
+ })()
363
+ }, 400)
364
+ pendingRefresh.set(sessionID, timer)
365
+ }
366
+
367
+ const unsubscribe = (() => {
368
+ let stopped = false
369
+ const run = async () => {
370
+ try {
371
+ const stream = context.event!.subscribe!({ signal: context.signal })
372
+ for await (const event of stream) {
373
+ if (stopped) break
374
+ if (!event || typeof event.type !== "string") continue
375
+ const sid = eventSessionID(event)
376
+ if (!sid) continue
377
+ const type = event.type
378
+
379
+ if (type === "session.deleted") {
380
+ await engine.hooks.event({ event: { type, properties: { sessionID: sid } } })
381
+ continue
382
+ }
383
+ if (type === "message.removed" || type === "message.part.removed") {
384
+ // undo cleanup: forwarded verbatim — the engine emits the
385
+ // tombstones that purge the reverted items from the share
386
+ const data = (event.data ?? {}) as { messageID?: string; partID?: string }
387
+ await engine.hooks.event({
388
+ event: { type, properties: { sessionID: sid, messageID: data.messageID, partID: data.partID } },
389
+ })
390
+ continue
391
+ }
392
+ if (SESSION_ITEM_EVENTS.has(type)) {
393
+ refresh(sid)
394
+ continue
395
+ }
396
+ if (BUSY_EVENTS.has(type)) {
397
+ await engine.hooks.event({ event: { type: "session.status", properties: { sessionID: sid, status: { type: "busy" } } } })
398
+ refresh(sid)
399
+ continue
400
+ }
401
+ if (IDLE_EVENTS.has(type)) {
402
+ await engine.hooks.event({ event: { type: "session.status", properties: { sessionID: sid, status: { type: "idle" } } } })
403
+ refresh(sid)
404
+ continue
405
+ }
406
+ if (type === "session.error" || type === "session.execution.failed") {
407
+ // terminal failure: the share says error at the next idle
408
+ // (recovered retries never set it)
409
+ await engine.hooks.event({ event: { type: "session.error", properties: { sessionID: sid } } })
410
+ continue
411
+ }
412
+ if (ERROR_EVENTS.has(type)) {
413
+ await engine.hooks.event({ event: { type: "session.status", properties: { sessionID: sid, status: { type: "retry" } } } })
414
+ continue
415
+ }
416
+ if (CONTENT_EVENTS_PREFIXES.some((prefix) => type.startsWith(prefix))) {
417
+ refresh(sid)
418
+ continue
419
+ }
420
+ }
421
+ } catch {
422
+ /* stream ended or aborted */
423
+ }
424
+ }
425
+ void run()
426
+ return () => {
427
+ stopped = true
428
+ }
429
+ })()
430
+
431
+ return () => {
432
+ unsubscribe()
433
+ for (const timer of pendingRefresh.values()) clearTimeout(timer)
434
+ pendingRefresh.clear()
435
+ void engine.hooks.dispose()
436
+ }
437
+ }
438
+ }