@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
package/src/status.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Live status of a voice session and the subagent sessions it spawns, built from OpenCode events.
|
|
2
|
+
|
|
3
|
+
export type SessionState = "idle" | "working" | "blocked" | "error"
|
|
4
|
+
|
|
5
|
+
export interface Blocker {
|
|
6
|
+
readonly kind: "permission" | "form"
|
|
7
|
+
readonly id: string
|
|
8
|
+
readonly summary: string
|
|
9
|
+
/** form: asked by the agent's question tool (metadata.kind "question"). */
|
|
10
|
+
readonly question?: true
|
|
11
|
+
/** form: its fields, bounded, so a phone can render and answer it. */
|
|
12
|
+
readonly fields?: readonly FormFieldSpec[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FormOptionSpec {
|
|
16
|
+
readonly value: string
|
|
17
|
+
readonly label: string
|
|
18
|
+
readonly description?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One field of an OpenCode form (FormField), with only what a client needs to render and validate it. */
|
|
22
|
+
export interface FormFieldSpec {
|
|
23
|
+
readonly key: string
|
|
24
|
+
readonly type: "string" | "number" | "integer" | "boolean" | "multiselect" | "external"
|
|
25
|
+
readonly title?: string
|
|
26
|
+
readonly description?: string
|
|
27
|
+
readonly required?: boolean
|
|
28
|
+
readonly options?: readonly FormOptionSpec[]
|
|
29
|
+
/** string/multiselect with options: free text allowed besides the options. */
|
|
30
|
+
readonly custom?: boolean
|
|
31
|
+
readonly placeholder?: string
|
|
32
|
+
readonly default?: string | number | boolean | readonly string[]
|
|
33
|
+
readonly minimum?: number
|
|
34
|
+
readonly maximum?: number
|
|
35
|
+
readonly minLength?: number
|
|
36
|
+
readonly maxLength?: number
|
|
37
|
+
readonly minItems?: number
|
|
38
|
+
readonly maxItems?: number
|
|
39
|
+
readonly format?: string
|
|
40
|
+
readonly pattern?: string
|
|
41
|
+
/** external: a link the user follows. */
|
|
42
|
+
readonly url?: string
|
|
43
|
+
/** Shown only when these conditions on other fields hold. */
|
|
44
|
+
readonly when?: ReadonlyArray<{ readonly key: string; readonly op: "eq" | "neq"; readonly value: string | number | boolean }>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const FORM_MAX_FIELDS = 20
|
|
48
|
+
export const FORM_MAX_OPTIONS = 50
|
|
49
|
+
const FORM_TEXT_MAX = 2000
|
|
50
|
+
const FORM_LABEL_MAX = 300
|
|
51
|
+
const FIELD_TYPES = new Set<FormFieldSpec["type"]>(["string", "number", "integer", "boolean", "multiselect", "external"])
|
|
52
|
+
|
|
53
|
+
const text = (v: unknown, max: number) => (typeof v === "string" && v ? v.slice(0, max) : undefined)
|
|
54
|
+
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
|
|
55
|
+
|
|
56
|
+
function optionSpec(raw: any): FormOptionSpec | undefined {
|
|
57
|
+
const value = text(raw?.value, FORM_LABEL_MAX)
|
|
58
|
+
if (value === undefined) return undefined
|
|
59
|
+
const description = text(raw.description, FORM_TEXT_MAX)
|
|
60
|
+
return { value, label: text(raw.label, FORM_LABEL_MAX) ?? value, ...(description ? { description } : {}) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The renderable part of an OpenCode form field; undefined for fields a client cannot show. */
|
|
64
|
+
export function formFieldSpec(raw: any): FormFieldSpec | undefined {
|
|
65
|
+
const key = text(raw?.key, FORM_LABEL_MAX)
|
|
66
|
+
if (!key || !FIELD_TYPES.has(raw.type)) return undefined
|
|
67
|
+
const out: Record<string, unknown> = { key, type: raw.type }
|
|
68
|
+
for (const name of ["title", "placeholder", "format", "pattern"] as const) if (text(raw[name], FORM_LABEL_MAX)) out[name] = text(raw[name], FORM_LABEL_MAX)
|
|
69
|
+
if (text(raw.description, FORM_TEXT_MAX)) out.description = text(raw.description, FORM_TEXT_MAX)
|
|
70
|
+
if (raw.type === "external") {
|
|
71
|
+
const url = text(raw.url, FORM_TEXT_MAX)
|
|
72
|
+
if (!url || !/^https?:\/\//.test(url)) return undefined
|
|
73
|
+
out.url = url
|
|
74
|
+
}
|
|
75
|
+
if (raw.required === true) out.required = true
|
|
76
|
+
if (raw.custom === true) out.custom = true
|
|
77
|
+
if (Array.isArray(raw.options)) {
|
|
78
|
+
const options = raw.options.slice(0, FORM_MAX_OPTIONS).map(optionSpec).filter(Boolean)
|
|
79
|
+
if (options.length) out.options = options
|
|
80
|
+
}
|
|
81
|
+
for (const name of ["minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"] as const) if (num(raw[name]) !== undefined) out[name] = raw[name]
|
|
82
|
+
const d = raw.default
|
|
83
|
+
if (typeof d === "boolean" || num(d) !== undefined) out.default = d
|
|
84
|
+
else if (typeof d === "string") out.default = d.slice(0, FORM_TEXT_MAX)
|
|
85
|
+
else if (Array.isArray(d)) out.default = d.filter((x: unknown) => typeof x === "string").slice(0, FORM_MAX_OPTIONS)
|
|
86
|
+
if (Array.isArray(raw.when)) {
|
|
87
|
+
const when = raw.when
|
|
88
|
+
.filter((w: any) => typeof w?.key === "string" && (w.op === "eq" || w.op === "neq") && ["string", "number", "boolean"].includes(typeof w.value))
|
|
89
|
+
.slice(0, 10)
|
|
90
|
+
.map((w: any) => ({ key: w.key, op: w.op, value: w.value }))
|
|
91
|
+
if (when.length) out.when = when
|
|
92
|
+
}
|
|
93
|
+
return out as unknown as FormFieldSpec
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A form.created event's form as a blocker. */
|
|
97
|
+
function formBlocker(form: any): Blocker {
|
|
98
|
+
const fields = Array.isArray(form.fields) ? form.fields.map(formFieldSpec).filter(Boolean).slice(0, FORM_MAX_FIELDS) : []
|
|
99
|
+
return {
|
|
100
|
+
kind: "form",
|
|
101
|
+
id: form.id,
|
|
102
|
+
summary: typeof form.title === "string" && form.title ? form.title.slice(0, FORM_LABEL_MAX) : "question",
|
|
103
|
+
...(form.metadata?.kind === "question" ? { question: true as const } : {}),
|
|
104
|
+
...(fields.length ? { fields } : {}),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface SessionStatus {
|
|
109
|
+
readonly id: string
|
|
110
|
+
readonly parentID?: string
|
|
111
|
+
readonly title: string
|
|
112
|
+
readonly state: SessionState
|
|
113
|
+
readonly detail?: string
|
|
114
|
+
readonly blockers: ReadonlyArray<Blocker>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface Entry {
|
|
118
|
+
id: string
|
|
119
|
+
parentID?: string
|
|
120
|
+
title: string
|
|
121
|
+
running: boolean
|
|
122
|
+
error?: string
|
|
123
|
+
blockers: Blocker[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface EventLike {
|
|
127
|
+
readonly type: string
|
|
128
|
+
readonly data?: unknown
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class StatusBoard {
|
|
132
|
+
private readonly sessions = new Map<string, Entry>()
|
|
133
|
+
|
|
134
|
+
constructor(rootID: string, title: string) {
|
|
135
|
+
this.sessions.set(rootID, { id: rootID, title, running: false, blockers: [] })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Applies an event. Returns true when the visible status changed. */
|
|
139
|
+
observe(event: EventLike): boolean {
|
|
140
|
+
const data = (event.data ?? {}) as Record<string, any>
|
|
141
|
+
switch (event.type) {
|
|
142
|
+
case "session.created": {
|
|
143
|
+
if (typeof data.parentID !== "string" || !this.sessions.has(data.parentID)) return false
|
|
144
|
+
this.sessions.set(data.sessionID, {
|
|
145
|
+
id: data.sessionID,
|
|
146
|
+
parentID: data.parentID,
|
|
147
|
+
title: data.title ?? data.agent ?? data.slug ?? "subagent",
|
|
148
|
+
running: false,
|
|
149
|
+
blockers: [],
|
|
150
|
+
})
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
case "session.renamed":
|
|
154
|
+
return this.update(data.sessionID, (entry) => (entry.title = data.title))
|
|
155
|
+
case "session.deleted":
|
|
156
|
+
return this.sessions.delete(data.sessionID)
|
|
157
|
+
case "session.execution.started":
|
|
158
|
+
return this.update(data.sessionID, (entry) => {
|
|
159
|
+
entry.running = true
|
|
160
|
+
entry.error = undefined
|
|
161
|
+
})
|
|
162
|
+
case "session.execution.succeeded":
|
|
163
|
+
case "session.execution.interrupted":
|
|
164
|
+
return this.update(data.sessionID, (entry) => (entry.running = false))
|
|
165
|
+
case "session.execution.failed":
|
|
166
|
+
return this.update(data.sessionID, (entry) => {
|
|
167
|
+
entry.running = false
|
|
168
|
+
entry.error = String(data.error?.message ?? "failed")
|
|
169
|
+
})
|
|
170
|
+
case "permission.asked":
|
|
171
|
+
return this.update(data.sessionID, (entry) => {
|
|
172
|
+
if (entry.blockers.some((b) => b.id === data.id)) return
|
|
173
|
+
entry.blockers.push({
|
|
174
|
+
kind: "permission",
|
|
175
|
+
id: data.id,
|
|
176
|
+
summary: [data.action, (data.resources ?? []).join(", ")].filter(Boolean).join(": "),
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
case "permission.replied":
|
|
180
|
+
return this.unblock(data.sessionID, data.requestID)
|
|
181
|
+
case "form.created":
|
|
182
|
+
return this.update(data.form?.sessionID, (entry) => {
|
|
183
|
+
if (entry.blockers.some((b) => b.id === data.form.id)) return
|
|
184
|
+
entry.blockers.push(formBlocker(data.form))
|
|
185
|
+
})
|
|
186
|
+
case "form.replied":
|
|
187
|
+
case "form.cancelled":
|
|
188
|
+
return this.unblock(data.sessionID, data.id)
|
|
189
|
+
default:
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get(id: string): SessionStatus | undefined {
|
|
195
|
+
const entry = this.sessions.get(id)
|
|
196
|
+
return entry && view(entry)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
ids(): string[] {
|
|
200
|
+
return [...this.sessions.keys()]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
snapshot(): { sessions: SessionStatus[] } {
|
|
204
|
+
return { sessions: [...this.sessions.values()].map(view) }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private update(id: unknown, change: (entry: Entry) => void): boolean {
|
|
208
|
+
const entry = typeof id === "string" ? this.sessions.get(id) : undefined
|
|
209
|
+
if (!entry) return false
|
|
210
|
+
change(entry)
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private unblock(sessionID: unknown, blockerID: unknown): boolean {
|
|
215
|
+
return this.update(sessionID, (entry) => {
|
|
216
|
+
entry.blockers = entry.blockers.filter((blocker) => blocker.id !== blockerID)
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function view(entry: Entry): SessionStatus {
|
|
222
|
+
const state: SessionState =
|
|
223
|
+
entry.blockers.length > 0 ? "blocked" : entry.error ? "error" : entry.running ? "working" : "idle"
|
|
224
|
+
return {
|
|
225
|
+
id: entry.id,
|
|
226
|
+
...(entry.parentID ? { parentID: entry.parentID } : {}),
|
|
227
|
+
title: entry.title,
|
|
228
|
+
state,
|
|
229
|
+
...(entry.error && state === "error" ? { detail: entry.error } : {}),
|
|
230
|
+
blockers: entry.blockers.map((blocker) => structuredClone(blocker)),
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Hub's TUI slash commands, one entry per feature. /hub configures this harness's connection to the hub;
|
|
2
|
+
// every other entry is a feature (/voice today). To add a feature, write its flow in its own module
|
|
3
|
+
// (like src/voice-command.ts) and add one HubCommand to the list src/tui.ts passes to hubCommands().
|
|
4
|
+
|
|
5
|
+
/** The palette group of every Hub command. */
|
|
6
|
+
export const COMMAND_GROUP = "Hub"
|
|
7
|
+
/** Command IDs are `hub.<id>`, the plugin's namespace. */
|
|
8
|
+
export const COMMAND_PREFIX = "hub."
|
|
9
|
+
|
|
10
|
+
export interface HubCommand {
|
|
11
|
+
/** Unique within Hub; the command ID is `hub.<id>`. */
|
|
12
|
+
readonly id: string
|
|
13
|
+
/** The slash command, without the "/". */
|
|
14
|
+
readonly slash: string
|
|
15
|
+
/** Shown in the command palette after "Hub: ". */
|
|
16
|
+
readonly title: string
|
|
17
|
+
readonly run: () => Promise<unknown>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The shape OpenCode's keymap layer takes for one command. */
|
|
21
|
+
export interface KeymapCommand {
|
|
22
|
+
readonly id: string
|
|
23
|
+
readonly title: string
|
|
24
|
+
readonly group: string
|
|
25
|
+
readonly palette: true
|
|
26
|
+
readonly slash: { readonly name: string }
|
|
27
|
+
readonly run: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Keymap commands for the given features. Throws on a duplicate ID or slash name (a programming error).
|
|
32
|
+
* A failing run is reported through `onError` (a toast), never thrown into the TUI.
|
|
33
|
+
*/
|
|
34
|
+
export function hubCommands(features: readonly HubCommand[], onError: (cause: unknown) => void): KeymapCommand[] {
|
|
35
|
+
const ids = new Set<string>()
|
|
36
|
+
const names = new Set<string>()
|
|
37
|
+
return features.map((feature) => {
|
|
38
|
+
if (ids.has(feature.id)) throw new Error(`duplicate Hub command id "${feature.id}"`)
|
|
39
|
+
if (names.has(feature.slash)) throw new Error(`duplicate Hub slash command "/${feature.slash}"`)
|
|
40
|
+
ids.add(feature.id)
|
|
41
|
+
names.add(feature.slash)
|
|
42
|
+
return {
|
|
43
|
+
id: `${COMMAND_PREFIX}${feature.id}`,
|
|
44
|
+
title: `${COMMAND_GROUP}: ${feature.title}`,
|
|
45
|
+
group: COMMAND_GROUP,
|
|
46
|
+
palette: true,
|
|
47
|
+
slash: { name: feature.slash },
|
|
48
|
+
run: () => {
|
|
49
|
+
void feature.run().catch(onError)
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|
package/src/tui-forms.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// TUI side of form (question) replies from the hub: a standalone server started by this TUI cannot call its
|
|
2
|
+
// own HTTP API (its password stays with the TUI), so its spoke queues the replies and the TUI long-polls
|
|
3
|
+
// RPC hub.formActions and carries them out with its client. See Spoke.formActions.
|
|
4
|
+
|
|
5
|
+
export type RelayedFormAction =
|
|
6
|
+
| { readonly sessionID: string; readonly formID: string; readonly answer: Record<string, unknown> }
|
|
7
|
+
| { readonly sessionID: string; readonly formID: string; readonly cancel: true }
|
|
8
|
+
|
|
9
|
+
export interface FormRelayOptions {
|
|
10
|
+
/** RPC hub.formActions: waits up to `waitMs` for queued replies. */
|
|
11
|
+
readonly poll: (waitMs: number) => Promise<{ actions?: readonly unknown[] }>
|
|
12
|
+
/** Carries one out (client.session.form.reply / cancel). Errors are logged and skipped. */
|
|
13
|
+
readonly apply: (action: RelayedFormAction) => Promise<unknown>
|
|
14
|
+
readonly waitMs?: number
|
|
15
|
+
/** Pause after a failed poll (no spoke, older server, disconnected). */
|
|
16
|
+
readonly retryMs?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function action(raw: unknown): RelayedFormAction | undefined {
|
|
20
|
+
const a = raw as Record<string, unknown> | null
|
|
21
|
+
if (!a || typeof a.sessionID !== "string" || typeof a.formID !== "string") return undefined
|
|
22
|
+
if (a.cancel === true) return { sessionID: a.sessionID, formID: a.formID, cancel: true }
|
|
23
|
+
if (typeof a.answer === "object" && a.answer !== null && !Array.isArray(a.answer)) {
|
|
24
|
+
return { sessionID: a.sessionID, formID: a.formID, answer: a.answer as Record<string, unknown> }
|
|
25
|
+
}
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class FormRelay {
|
|
30
|
+
private running = false
|
|
31
|
+
private wake?: () => void
|
|
32
|
+
|
|
33
|
+
constructor(private readonly options: FormRelayOptions) {}
|
|
34
|
+
|
|
35
|
+
/** Polls until stop(); resolves when stopped. */
|
|
36
|
+
async start(): Promise<void> {
|
|
37
|
+
if (this.running) return
|
|
38
|
+
this.running = true
|
|
39
|
+
while (this.running) {
|
|
40
|
+
let actions: readonly unknown[] = []
|
|
41
|
+
try {
|
|
42
|
+
actions = (await this.options.poll(this.options.waitMs ?? 20_000)).actions ?? []
|
|
43
|
+
} catch {
|
|
44
|
+
await this.sleep(this.options.retryMs ?? 10_000)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
for (const raw of actions) {
|
|
48
|
+
const next = action(raw)
|
|
49
|
+
if (!next) continue
|
|
50
|
+
try {
|
|
51
|
+
await this.options.apply(next)
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
console.error("[hub] could not answer a question from the hub", cause instanceof Error ? cause.message : cause)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
stop(): void {
|
|
60
|
+
this.running = false
|
|
61
|
+
this.wake?.()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private sleep(ms: number): Promise<void> {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const timer = setTimeout(done, ms)
|
|
67
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
68
|
+
const self = this
|
|
69
|
+
function done() {
|
|
70
|
+
clearTimeout(timer)
|
|
71
|
+
self.wake = undefined
|
|
72
|
+
resolve()
|
|
73
|
+
}
|
|
74
|
+
this.wake = done
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// TUI presence: the TUI tells its server-side spoke which sessions it shows (the current route's root
|
|
2
|
+
// session and its open tabs) over RPC voice.presence, on change (the route is polled every 2 s) and every
|
|
3
|
+
// 15 s, so the phone can list them as open. See docs/session-view.md.
|
|
4
|
+
|
|
5
|
+
export const PRESENCE_POLL_MS = 2000
|
|
6
|
+
export const PRESENCE_HEARTBEAT_MS = 15_000
|
|
7
|
+
|
|
8
|
+
/** The root of the session the route shows, plus the open tabs (root sessions), deduplicated. */
|
|
9
|
+
export function shownSessions(input: {
|
|
10
|
+
route: { type: string; sessionID?: string }
|
|
11
|
+
root: (sessionID: string) => string
|
|
12
|
+
tabs?: readonly string[]
|
|
13
|
+
}): string[] {
|
|
14
|
+
const out = new Set<string>()
|
|
15
|
+
const current = input.route.type === "session" ? input.route.sessionID : undefined
|
|
16
|
+
if (current) {
|
|
17
|
+
let root = current
|
|
18
|
+
try {
|
|
19
|
+
root = input.root(current) || current
|
|
20
|
+
} catch {
|
|
21
|
+
// not synced yet: the session itself
|
|
22
|
+
}
|
|
23
|
+
out.add(root)
|
|
24
|
+
}
|
|
25
|
+
for (const id of input.tabs ?? []) if (id) out.add(id)
|
|
26
|
+
return [...out]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PresenceReporterOptions {
|
|
30
|
+
/** The sessions shown right now. */
|
|
31
|
+
readonly read: () => readonly string[]
|
|
32
|
+
/** Reports them (errors are ignored: the next tick retries). */
|
|
33
|
+
readonly send: (sessionIDs: readonly string[]) => Promise<unknown>
|
|
34
|
+
readonly pollMs?: number
|
|
35
|
+
readonly heartbeatMs?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PresenceReporter {
|
|
39
|
+
private timers: Array<ReturnType<typeof setInterval>> = []
|
|
40
|
+
private last?: string
|
|
41
|
+
|
|
42
|
+
constructor(private readonly options: PresenceReporterOptions) {}
|
|
43
|
+
|
|
44
|
+
start(): void {
|
|
45
|
+
if (this.timers.length) return
|
|
46
|
+
this.tick(true)
|
|
47
|
+
this.every(this.options.pollMs ?? PRESENCE_POLL_MS, () => this.tick(false))
|
|
48
|
+
this.every(this.options.heartbeatMs ?? PRESENCE_HEARTBEAT_MS, () => this.tick(true))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
stop(): void {
|
|
52
|
+
for (const timer of this.timers) clearInterval(timer)
|
|
53
|
+
this.timers = []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Reads now and reports when changed (or always, for the heartbeat). */
|
|
57
|
+
tick(always = false): void {
|
|
58
|
+
let ids: readonly string[]
|
|
59
|
+
try {
|
|
60
|
+
ids = this.options.read()
|
|
61
|
+
} catch {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
const key = [...ids].sort().join(" ")
|
|
65
|
+
if (!always && key === this.last) return
|
|
66
|
+
this.last = key
|
|
67
|
+
this.options.send(ids).catch(() => {})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private every(ms: number, run: () => void): void {
|
|
71
|
+
const timer = setInterval(run, ms)
|
|
72
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
73
|
+
this.timers.push(timer)
|
|
74
|
+
}
|
|
75
|
+
}
|