@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.
- package/CHANGELOG.md +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- 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
|
+
}
|