@huynle/hub 0.0.0-stage → 0.6.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1029 -2
  5. package/bin/hub.ts +20 -0
  6. package/docker/hub-deps.ts +42 -0
  7. package/package.json +85 -4
  8. package/rpc.ts +2 -0
  9. package/scripts/hub-local.sh +28 -0
  10. package/scripts/io.github.huynle.hub.plist +42 -0
  11. package/server.ts +3 -0
  12. package/src/central/assets.ts +74 -0
  13. package/src/central/auth.ts +271 -0
  14. package/src/central/config.ts +310 -0
  15. package/src/central/http.ts +1247 -0
  16. package/src/central/main.ts +136 -0
  17. package/src/central/oidc.ts +295 -0
  18. package/src/central/push-target.ts +97 -0
  19. package/src/central/push.ts +285 -0
  20. package/src/central/registry.ts +1153 -0
  21. package/src/central/speech/models.ts +89 -0
  22. package/src/central/speech/openai.ts +66 -0
  23. package/src/central/speech/openrouter.ts +71 -0
  24. package/src/central/speech/provider.ts +74 -0
  25. package/src/central/speech/service.ts +168 -0
  26. package/src/central/speech/settings.ts +375 -0
  27. package/src/central/spoke-ws.ts +300 -0
  28. package/src/central/store.ts +653 -0
  29. package/src/config.ts +170 -0
  30. package/src/env.ts +63 -0
  31. package/src/index.ts +236 -0
  32. package/src/key.ts +17 -0
  33. package/src/loopback.ts +41 -0
  34. package/src/migrate.ts +105 -0
  35. package/src/openrouter.ts +3 -0
  36. package/src/options.ts +33 -0
  37. package/src/protocol.ts +650 -0
  38. package/src/reply.ts +27 -0
  39. package/src/rpc.ts +98 -0
  40. package/src/setup.ts +124 -0
  41. package/src/speakable.ts +35 -0
  42. package/src/spoke/client.ts +272 -0
  43. package/src/spoke/conversation.ts +93 -0
  44. package/src/spoke/enroll.ts +79 -0
  45. package/src/spoke/herdr.ts +346 -0
  46. package/src/spoke/listing.ts +108 -0
  47. package/src/spoke/localhub.ts +287 -0
  48. package/src/spoke/locations.ts +34 -0
  49. package/src/spoke/own-server.ts +32 -0
  50. package/src/spoke/presence.ts +45 -0
  51. package/src/spoke/singleton.ts +103 -0
  52. package/src/spoke/spoke.ts +1212 -0
  53. package/src/spoke/summary.ts +117 -0
  54. package/src/spoke/timeline.ts +93 -0
  55. package/src/spoke/tracker.ts +345 -0
  56. package/src/spoke/watch.ts +78 -0
  57. package/src/status.ts +232 -0
  58. package/src/tui-commands.ts +53 -0
  59. package/src/tui-forms.ts +77 -0
  60. package/src/tui-presence.ts +75 -0
  61. package/src/tui.ts +479 -0
  62. package/src/turns.ts +94 -0
  63. package/src/voice-command.ts +65 -0
  64. package/tui.ts +2 -0
  65. package/web/dist/apple-touch-icon.png +0 -0
  66. package/web/dist/assets/index-DClBvfLL.css +1 -0
  67. package/web/dist/assets/index-Djo_yF0a.js +12 -0
  68. package/web/dist/favicon.ico +0 -0
  69. package/web/dist/icon-192.png +0 -0
  70. package/web/dist/icon-512.png +0 -0
  71. package/web/dist/icon-maskable-512.png +0 -0
  72. package/web/dist/icon.svg +18 -0
  73. package/web/dist/index.html +25 -0
  74. package/web/dist/manifest.webmanifest +18 -0
  75. package/web/dist/sw.js +151 -0
  76. package/web/dist/vad-worklet.js +27 -0
@@ -0,0 +1,79 @@
1
+ // Device-code enrollment of this machine with the central server (before it has a token).
2
+ // POST /spoke/enroll → EnrollStart; then POST /spoke/enroll/poll until the user approves it.
3
+ import type { EnrollPoll, EnrollStart } from "../protocol"
4
+
5
+ export type EnrollOutcome =
6
+ | { readonly status: "approved"; readonly machineID: string; readonly machineToken: string }
7
+ | { readonly status: "denied" | "expired" | "cancelled" }
8
+
9
+ export interface EnrollOptions {
10
+ readonly signal?: AbortSignal
11
+ readonly fetch?: typeof fetch
12
+ readonly now?: () => number
13
+ }
14
+
15
+ const REQUEST_TIMEOUT_MS = 15_000
16
+
17
+ async function post(server: string, path: string, body: unknown, options: EnrollOptions): Promise<unknown> {
18
+ const doFetch = options.fetch ?? fetch
19
+ const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
20
+ const response = await doFetch(`${server.replace(/\/+$/, "")}${path}`, {
21
+ method: "POST",
22
+ headers: { "content-type": "application/json" },
23
+ body: JSON.stringify(body),
24
+ signal: AbortSignal.any(signals),
25
+ })
26
+ if (!response.ok) throw new Error(`${path} failed: HTTP ${response.status}`)
27
+ return response.json()
28
+ }
29
+
30
+ function isStart(v: any): v is EnrollStart {
31
+ return (
32
+ typeof v === "object" &&
33
+ v !== null &&
34
+ typeof v.userCode === "string" &&
35
+ typeof v.deviceCode === "string" &&
36
+ typeof v.approveURL === "string" &&
37
+ typeof v.intervalMs === "number" &&
38
+ typeof v.expiresAt === "number"
39
+ )
40
+ }
41
+
42
+ export async function startEnrollment(server: string, machineName: string, options: EnrollOptions = {}): Promise<EnrollStart> {
43
+ const body = await post(server, "/spoke/enroll", { machineName }, options)
44
+ if (!isStart(body)) throw new Error("Invalid enrollment reply from the hub")
45
+ return body
46
+ }
47
+
48
+ const sleep = (ms: number, signal?: AbortSignal) =>
49
+ new Promise<void>((resolve) => {
50
+ const timer = setTimeout(done, ms)
51
+ function done() {
52
+ clearTimeout(timer)
53
+ signal?.removeEventListener("abort", done)
54
+ resolve()
55
+ }
56
+ signal?.addEventListener("abort", done, { once: true })
57
+ })
58
+
59
+ /** Polls until approved, denied, expired, or aborted. Transient errors are retried until expiry. */
60
+ export async function pollEnrollment(server: string, start: EnrollStart, options: EnrollOptions = {}): Promise<EnrollOutcome> {
61
+ const now = options.now ?? Date.now
62
+ const interval = Math.max(1, start.intervalMs)
63
+ for (;;) {
64
+ if (options.signal?.aborted) return { status: "cancelled" }
65
+ if (now() > start.expiresAt) return { status: "expired" }
66
+ await sleep(interval, options.signal)
67
+ if (options.signal?.aborted) return { status: "cancelled" }
68
+ let reply: Partial<EnrollPoll> & Record<string, unknown>
69
+ try {
70
+ reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options)) as typeof reply
71
+ } catch {
72
+ continue
73
+ }
74
+ if (reply?.status === "approved" && typeof reply.machineID === "string" && typeof reply.machineToken === "string") {
75
+ return { status: "approved", machineID: reply.machineID, machineToken: reply.machineToken }
76
+ }
77
+ if (reply?.status === "denied" || reply?.status === "expired") return { status: reply.status }
78
+ }
79
+ }
@@ -0,0 +1,346 @@
1
+ // Herdr (terminal workspace manager) integration: which OpenCode sessions are open in a Herdr pane, the
2
+ // machine's Herdr workspaces (targets for new sessions), and opening a session in a new Herdr tab.
3
+ // Everything goes through the `herdr` CLI. Read errors are silent: no herdr, or its server is down, means
4
+ // no Herdr data. Only the background service's spoke polls (one poller per machine).
5
+ import path from "node:path"
6
+ import type { HerdrWorkspace, TerminalInfo } from "../protocol"
7
+
8
+ /** Runs `herdr <args>` and resolves with its stdout; rejects on failure, non-zero exit or timeout. */
9
+ export type HerdrRunner = (args: string[], timeoutMs: number) => Promise<string>
10
+
11
+ /** Per-call limit for list commands. */
12
+ export const HERDR_TIMEOUT_MS = 3000
13
+ export const AGENTS_POLL_MS = 5000
14
+ export const WORKSPACES_POLL_MS = 30_000
15
+ /** How long `herdr agent start` waits for OpenCode to be ready in the new pane. */
16
+ export const AGENT_START_TIMEOUT_MS = 20_000
17
+
18
+ const STDERR_MAX = 300
19
+
20
+ export function herdrRunner(bin = "herdr"): HerdrRunner {
21
+ return async (args, timeoutMs) => {
22
+ const proc = Bun.spawn([bin, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" })
23
+ const what = `herdr ${args.slice(0, 2).join(" ")}`
24
+ const stdout = collect(proc.stdout)
25
+ const stderr = collect(proc.stderr)
26
+ let timer: ReturnType<typeof setTimeout> | undefined
27
+ const timeout = new Promise<"timeout">((resolve) => (timer = setTimeout(() => resolve("timeout"), timeoutMs)))
28
+ try {
29
+ const code = await Promise.race([proc.exited, timeout])
30
+ if (code === "timeout") {
31
+ proc.kill()
32
+ throw new Error(`${what} timed out after ${timeoutMs} ms`)
33
+ }
34
+ // A process herdr started in the background may keep the pipes open: read what is there, briefly.
35
+ const [out, err] = await Promise.all([stdout.settle(PIPE_GRACE_MS), stderr.settle(PIPE_GRACE_MS)])
36
+ if (code !== 0) throw new Error(`${what} failed (exit ${code}): ${(err || out).trim().slice(0, STDERR_MAX)}`)
37
+ return out
38
+ } finally {
39
+ clearTimeout(timer)
40
+ }
41
+ }
42
+ }
43
+
44
+ /** How long output is still read after the process exited. */
45
+ const PIPE_GRACE_MS = 200
46
+
47
+ /** Reads a stream in the background; settle() waits up to `ms` more for its end, then returns what arrived. */
48
+ function collect(stream: ReadableStream<Uint8Array>) {
49
+ const reader = stream.getReader()
50
+ const decoder = new TextDecoder()
51
+ let text = ""
52
+ const done = (async () => {
53
+ try {
54
+ for (;;) {
55
+ const { done, value } = await reader.read()
56
+ if (done) return
57
+ text += decoder.decode(value, { stream: true })
58
+ }
59
+ } catch {
60
+ // cancelled or broken: keep what arrived
61
+ }
62
+ })()
63
+ return {
64
+ async settle(ms: number): Promise<string> {
65
+ let timer: ReturnType<typeof setTimeout> | undefined
66
+ await Promise.race([done, new Promise((resolve) => (timer = setTimeout(resolve, ms)))])
67
+ clearTimeout(timer)
68
+ reader.cancel().catch(() => {})
69
+ return text
70
+ },
71
+ }
72
+ }
73
+
74
+ // ---------- parsing (defensive: any unexpected shape yields nothing) ----------
75
+
76
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
77
+ const text = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined)
78
+
79
+ /** `.result[key]` as an array of objects; [] for anything else. */
80
+ function resultList(stdout: string, key: string): Array<Record<string, unknown>> {
81
+ let value: unknown
82
+ try {
83
+ value = JSON.parse(stdout)
84
+ } catch {
85
+ return []
86
+ }
87
+ const list = isObj(value) && isObj(value.result) ? value.result[key] : undefined
88
+ return Array.isArray(list) ? list.filter(isObj) : []
89
+ }
90
+
91
+ /** Herdr's error message in a JSON answer, if any. */
92
+ function resultError(stdout: string): string | undefined {
93
+ try {
94
+ const value = JSON.parse(stdout)
95
+ if (!isObj(value) || value.error === undefined) return undefined
96
+ const error = value.error
97
+ return isObj(error) ? (text(error.message) ?? text(error.code) ?? "herdr error") : String(error)
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
102
+
103
+ export interface HerdrAgent {
104
+ readonly sessionID: string
105
+ readonly paneID: string
106
+ readonly workspaceID: string
107
+ readonly cwd?: string
108
+ readonly title?: string
109
+ readonly status: NonNullable<TerminalInfo["status"]>
110
+ }
111
+
112
+ const STATUSES = new Set(["idle", "working", "blocked", "done", "unknown"])
113
+ const TITLE_PREFIX = /^OC \| /
114
+
115
+ /** OpenCode panes from `herdr agent list` that report a session ID. */
116
+ export function parseAgents(stdout: string): HerdrAgent[] {
117
+ const out: HerdrAgent[] = []
118
+ for (const a of resultList(stdout, "agents")) {
119
+ const sessionID = isObj(a.agent_session) ? text(a.agent_session.value) : undefined
120
+ const paneID = text(a.pane_id)
121
+ const workspaceID = text(a.workspace_id)
122
+ if (a.agent !== "opencode" || !sessionID?.startsWith("ses") || !paneID || !workspaceID) continue
123
+ const cwd = text(a.cwd)
124
+ const title = text(text(a.terminal_title_stripped)?.replace(TITLE_PREFIX, "").trim())
125
+ const status = STATUSES.has(a.agent_status as string) ? (a.agent_status as HerdrAgent["status"]) : "unknown"
126
+ out.push({ sessionID, paneID, workspaceID, ...(cwd ? { cwd } : {}), ...(title ? { title } : {}), status })
127
+ }
128
+ return out
129
+ }
130
+
131
+ /** Workspaces from `herdr workspace list`, in Herdr's order. */
132
+ export function parseWorkspaces(stdout: string): Array<{ id: string; label: string; number: number }> {
133
+ const out: Array<{ id: string; label: string; number: number }> = []
134
+ for (const w of resultList(stdout, "workspaces")) {
135
+ const id = text(w.workspace_id)
136
+ if (!id) continue
137
+ out.push({ id, label: typeof w.label === "string" ? w.label : id, number: typeof w.number === "number" ? w.number : Number.MAX_SAFE_INTEGER })
138
+ }
139
+ return out.sort((a, b) => a.number - b.number)
140
+ }
141
+
142
+ /** Pane folders from `herdr pane list`. */
143
+ export function parsePanes(stdout: string): Array<{ workspaceID: string; cwd: string }> {
144
+ const out: Array<{ workspaceID: string; cwd: string }> = []
145
+ for (const p of resultList(stdout, "panes")) {
146
+ const workspaceID = text(p.workspace_id)
147
+ const cwd = text(p.cwd)
148
+ if (workspaceID && cwd?.startsWith("/")) out.push({ workspaceID, cwd })
149
+ }
150
+ return out
151
+ }
152
+
153
+ const AGENT_NAME_MAX = 48
154
+
155
+ /** Herdr agent name for a session: a slug of its title plus the end of its ID (unique enough, readable). */
156
+ export function agentName(title: string | undefined, sessionID: string): string {
157
+ const suffix = sessionID.slice(-6).toLowerCase().replace(/[^a-z0-9]/g, "") || "new"
158
+ const slug =
159
+ (title ?? "")
160
+ .toLowerCase()
161
+ .replace(/[^a-z0-9]+/g, "-")
162
+ .replace(/^-+|-+$/g, "")
163
+ .slice(0, AGENT_NAME_MAX - suffix.length - 1)
164
+ .replace(/-+$/g, "") || "opencode"
165
+ return `${slug}-${suffix}`
166
+ }
167
+
168
+ // ---------- polling ----------
169
+
170
+ export interface HerdrWatcherOptions {
171
+ readonly run: HerdrRunner
172
+ /** Called when the open sessions or the workspaces changed. */
173
+ readonly onChange: () => void
174
+ readonly agentsMs?: number
175
+ readonly workspacesMs?: number
176
+ readonly timeoutMs?: number
177
+ }
178
+
179
+ const normalizeDir = (directory: string) => path.posix.resolve(directory)
180
+
181
+ /** Polls Herdr for open OpenCode sessions (every 5 s) and workspaces with their pane folders (every 30 s). */
182
+ export class HerdrWatcher {
183
+ private agents: HerdrAgent[] = []
184
+ private spaces?: Array<{ id: string; label: string }>
185
+ private panes: Array<{ workspaceID: string; cwd: string }> = []
186
+ private key = ""
187
+ private timers: Array<ReturnType<typeof setInterval>> = []
188
+ private busy = { agents: false, workspaces: false }
189
+ private running = false
190
+
191
+ constructor(private readonly options: HerdrWatcherOptions) {}
192
+
193
+ start(): void {
194
+ if (this.running) return
195
+ this.running = true
196
+ void this.refresh()
197
+ this.every(this.options.agentsMs ?? AGENTS_POLL_MS, () => void this.pollAgents())
198
+ this.every(this.options.workspacesMs ?? WORKSPACES_POLL_MS, () => void this.pollWorkspaces())
199
+ }
200
+
201
+ stop(): void {
202
+ this.running = false
203
+ for (const timer of this.timers) clearInterval(timer)
204
+ this.timers = []
205
+ this.agents = []
206
+ this.spaces = undefined
207
+ this.panes = []
208
+ this.key = ""
209
+ }
210
+
211
+ /** Reads agents and workspaces now. */
212
+ async refresh(): Promise<void> {
213
+ await Promise.all([this.pollWorkspaces(false), this.pollAgents(false)])
214
+ this.changed()
215
+ }
216
+
217
+ /** Sessions open in a Herdr pane → where. */
218
+ open(): Map<string, TerminalInfo> {
219
+ const labels = new Map((this.spaces ?? []).map((w) => [w.id, w.label]))
220
+ const out = new Map<string, TerminalInfo>()
221
+ for (const a of this.agents) {
222
+ if (out.has(a.sessionID)) continue
223
+ const label = labels.get(a.workspaceID)
224
+ out.set(a.sessionID, {
225
+ source: "herdr",
226
+ workspaceID: a.workspaceID,
227
+ ...(label !== undefined ? { workspace: label } : {}),
228
+ ...(a.title ? { title: a.title } : {}),
229
+ status: a.status,
230
+ })
231
+ }
232
+ return out
233
+ }
234
+
235
+ /** Herdr workspaces with their pane folders, most used first; undefined without Herdr. */
236
+ workspaces(): HerdrWorkspace[] | undefined {
237
+ if (!this.spaces) return undefined
238
+ const folders = [...this.panes, ...this.agents.flatMap((a) => (a.cwd ? [{ workspaceID: a.workspaceID, cwd: a.cwd }] : []))]
239
+ return this.spaces.map((w) => {
240
+ const counts = new Map<string, number>()
241
+ for (const f of folders) if (f.workspaceID === w.id) counts.set(normalizeDir(f.cwd), (counts.get(normalizeDir(f.cwd)) ?? 0) + 1)
242
+ const directories = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([dir]) => dir)
243
+ return { id: w.id, label: w.label, directories }
244
+ })
245
+ }
246
+
247
+ /** True when a Herdr pane (or OpenCode agent) is in the directory. */
248
+ hasDirectory(directory: string): boolean {
249
+ if (!directory.startsWith("/")) return false
250
+ const target = normalizeDir(directory)
251
+ return (this.workspaces() ?? []).some((w) => w.directories.includes(target))
252
+ }
253
+
254
+ hasWorkspace(id: string): boolean {
255
+ return (this.spaces ?? []).some((w) => w.id === id)
256
+ }
257
+
258
+ private async pollAgents(notify = true): Promise<void> {
259
+ if (this.busy.agents) return
260
+ this.busy.agents = true
261
+ try {
262
+ this.agents = parseAgents(await this.options.run(["agent", "list"], this.timeout()))
263
+ } catch {
264
+ this.agents = []
265
+ } finally {
266
+ this.busy.agents = false
267
+ }
268
+ if (notify) this.changed()
269
+ }
270
+
271
+ private async pollWorkspaces(notify = true): Promise<void> {
272
+ if (this.busy.workspaces) return
273
+ this.busy.workspaces = true
274
+ try {
275
+ const [spaces, panes] = await Promise.all([
276
+ this.options.run(["workspace", "list"], this.timeout()).then(parseWorkspaces),
277
+ this.options.run(["pane", "list"], this.timeout()).then(parsePanes, () => []),
278
+ ])
279
+ this.spaces = spaces.map(({ id, label }) => ({ id, label }))
280
+ this.panes = panes
281
+ } catch {
282
+ this.spaces = undefined
283
+ this.panes = []
284
+ } finally {
285
+ this.busy.workspaces = false
286
+ }
287
+ if (notify) this.changed()
288
+ }
289
+
290
+ private timeout(): number {
291
+ return this.options.timeoutMs ?? HERDR_TIMEOUT_MS
292
+ }
293
+
294
+ private changed(): void {
295
+ const key = JSON.stringify([[...this.open()], this.workspaces() ?? null])
296
+ if (key === this.key) return
297
+ this.key = key
298
+ try {
299
+ this.options.onChange()
300
+ } catch (cause) {
301
+ console.error("[hub] herdr change handler failed", cause)
302
+ }
303
+ }
304
+
305
+ private every(ms: number, run: () => void): void {
306
+ const timer = setInterval(run, ms)
307
+ ;(timer as { unref?: () => void }).unref?.()
308
+ this.timers.push(timer)
309
+ }
310
+ }
311
+
312
+ // ---------- opening a session ----------
313
+
314
+ export interface OpenInHerdrInput {
315
+ readonly workspaceID: string
316
+ readonly directory: string
317
+ readonly sessionID: string
318
+ readonly title?: string
319
+ }
320
+
321
+ /**
322
+ * Opens a session in a new tab of a Herdr workspace: `herdr tab create --workspace <id> --cwd <dir>`, then
323
+ * `herdr agent start <name> --kind opencode --pane <root pane> -- --session <id>` in its root pane.
324
+ */
325
+ export async function openInHerdr(run: HerdrRunner, input: OpenInHerdrInput, options: { settleMs?: number } = {}): Promise<void> {
326
+ const label = input.title?.trim() ? ["--label", input.title.trim().slice(0, 80)] : []
327
+ const tab = await run(["tab", "create", "--workspace", input.workspaceID, "--cwd", input.directory, ...label, "--no-focus"], HERDR_TIMEOUT_MS * 2)
328
+ const tabError = resultError(tab)
329
+ if (tabError) throw new Error(`herdr tab create: ${tabError}`)
330
+ let paneID: string | undefined
331
+ try {
332
+ const value = JSON.parse(tab)
333
+ paneID = isObj(value) && isObj(value.result) && isObj(value.result.root_pane) ? text(value.result.root_pane.pane_id) : undefined
334
+ } catch {
335
+ // no pane
336
+ }
337
+ if (!paneID) throw new Error("herdr tab create returned no pane")
338
+ // The new pane's shell needs a moment to reach its prompt.
339
+ if ((options.settleMs ?? 500) > 0) await Bun.sleep(options.settleMs ?? 500)
340
+ const started = await run(
341
+ ["agent", "start", agentName(input.title, input.sessionID), "--kind", "opencode", "--pane", paneID, "--timeout", String(AGENT_START_TIMEOUT_MS), "--", "--session", input.sessionID],
342
+ AGENT_START_TIMEOUT_MS + 5000,
343
+ )
344
+ const startError = resultError(started)
345
+ if (startError) throw new Error(`herdr agent start: ${startError}`)
346
+ }
@@ -0,0 +1,108 @@
1
+ // The background service's session listing for the phone's session browser: the most recent root
2
+ // sessions (no subagent children, nothing archived) within the history window, plus sessions that must
3
+ // always be listed (open in a terminal, running, ...), each with the workspace it belongs to.
4
+ import path from "node:path"
5
+
6
+ export interface RawSession {
7
+ readonly id: string
8
+ readonly title?: string
9
+ readonly parentID?: string
10
+ readonly projectID?: string
11
+ /** Path of the session's directory inside its project, e.g. "packages/app". */
12
+ readonly subpath?: string
13
+ readonly location: { readonly directory: string }
14
+ readonly time: { readonly updated: number; readonly archived?: number }
15
+ }
16
+
17
+ export interface RawProject {
18
+ readonly id: string
19
+ /** Project root; "/" for the global project of directories outside any repository. */
20
+ readonly canonical: string
21
+ readonly name?: string
22
+ }
23
+
24
+ /** The parts of the OpenCode client the listing uses. */
25
+ export interface ListingClient {
26
+ readonly session: {
27
+ list(input: { limit?: number; parentID?: null; cursor?: string }): Promise<{ data: readonly RawSession[]; cursor: { next?: string | null } }>
28
+ /** One session by ID (for always-included sessions outside the window). */
29
+ get?(input: { sessionID: string }): Promise<RawSession>
30
+ }
31
+ readonly project: { list(): Promise<readonly RawProject[]> }
32
+ }
33
+
34
+ export interface ListedSession {
35
+ readonly id: string
36
+ readonly title: string
37
+ readonly directory: string
38
+ readonly updated: number
39
+ readonly project: string
40
+ readonly projectName: string
41
+ }
42
+
43
+ export interface ListingOptions {
44
+ /** Most sessions returned (default 500). */
45
+ readonly max?: number
46
+ readonly pageSize?: number
47
+ /** Pages read at most, so a long run of filtered-out sessions cannot loop for long (default 20). */
48
+ readonly maxPages?: number
49
+ /** History window: only sessions updated in the last `days` days (default: no limit). */
50
+ readonly days?: number
51
+ readonly now?: number
52
+ /** Session IDs listed even outside the window or over `max` (fetched one by one when missing). */
53
+ readonly include?: readonly string[]
54
+ }
55
+
56
+ /** Most always-included sessions fetched one by one per listing. */
57
+ export const INCLUDE_FETCH_MAX = 50
58
+ const DAY_MS = 86_400_000
59
+
60
+ /** Workspace root and name of a session: its project, else its directory minus its subpath. */
61
+ export function workspaceOf(session: RawSession, projects: ReadonlyMap<string, RawProject>): { project: string; projectName: string } {
62
+ const known = session.projectID ? projects.get(session.projectID) : undefined
63
+ if (known && known.canonical && known.canonical !== "/") {
64
+ return { project: known.canonical, projectName: known.name || path.basename(known.canonical) }
65
+ }
66
+ const directory = session.location.directory
67
+ const subpath = session.subpath?.replace(/^\/+|\/+$/g, "")
68
+ const root = subpath && directory.endsWith(`/${subpath}`) ? directory.slice(0, -subpath.length - 1) || "/" : directory
69
+ return { project: root, projectName: path.basename(root) || root }
70
+ }
71
+
72
+ export async function listServiceSessions(client: ListingClient, options: ListingOptions = {}): Promise<ListedSession[]> {
73
+ const max = options.max ?? 500
74
+ const pageSize = options.pageSize ?? 100
75
+ const maxPages = options.maxPages ?? 20
76
+ const projects = new Map<string, RawProject>()
77
+ try {
78
+ for (const project of await client.project.list()) projects.set(project.id, project)
79
+ } catch {
80
+ // workspaces fall back to directories
81
+ }
82
+ const cutoff = options.days !== undefined ? (options.now ?? Date.now()) - options.days * DAY_MS : -Infinity
83
+ const listed = (s: RawSession): ListedSession => ({ id: s.id, title: s.title ?? "", directory: s.location.directory, updated: s.time.updated, ...workspaceOf(s, projects) })
84
+ const out: ListedSession[] = []
85
+ let cursor: string | undefined
86
+ // Sessions come newest first: the first one older than the window ends the listing.
87
+ let ended = false
88
+ for (let page = 0; page < maxPages && out.length < max && !ended; page++) {
89
+ const result = await client.session.list({ limit: pageSize, parentID: null, ...(cursor ? { cursor } : {}) })
90
+ for (const s of result.data) {
91
+ if (s.parentID || s.time.archived || out.length >= max) continue
92
+ if (s.time.updated < cutoff) {
93
+ ended = true
94
+ break
95
+ }
96
+ out.push(listed(s))
97
+ }
98
+ cursor = result.cursor.next ?? undefined
99
+ if (!cursor || result.data.length === 0) break
100
+ }
101
+ const have = new Set(out.map((s) => s.id))
102
+ const missing = [...new Set(options.include ?? [])].filter((id) => !have.has(id)).slice(0, INCLUDE_FETCH_MAX)
103
+ if (missing.length && client.session.get) {
104
+ const fetched = await Promise.all(missing.map((sessionID) => client.session.get!({ sessionID }).catch(() => undefined)))
105
+ for (const s of fetched) if (s && s.id && !s.parentID && !s.time?.archived && s.location?.directory) out.push(listed(s))
106
+ }
107
+ return out
108
+ }