@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,117 @@
1
+ // Right-rail summary of a session, computed on the spoke from OpenCode data: context usage (latest
2
+ // assistant tokens against the model's context limit), MCP server status and working-tree changes.
3
+ // Pure: the spoke passes in what it read from OpenCode.
4
+ import type { ChangedFile, ContextUsage, FilePatch, McpStatus, SessionChanges } from "../protocol"
5
+
6
+ /**
7
+ * Longest patch sent for one file, measured as JSON-encoded characters (quotes, backslashes and control
8
+ * characters count at their escaped size), so the reply always fits in one protocol frame. The rest is cut
9
+ * and marked truncated.
10
+ */
11
+ export const MAX_PATCH_CHARS = 400 * 1024
12
+ /** At most this many changed files are listed (the most-changed first); the totals still cover every file. */
13
+ export const MAX_CHANGED_FILES = 500
14
+ /** The listed files also stop once their JSON reaches this size, so the summary always fits in one frame. */
15
+ const MAX_CHANGES_ENCODED_CHARS = 400 * 1024
16
+ const MAX_MCP_SERVERS = 100
17
+ const MAX_NAME_CHARS = 200
18
+ const MAX_ERROR_CHARS = 200
19
+ const MCP_STATUSES = new Set(["connected", "pending", "disabled", "failed", "needs_auth"])
20
+ const FILE_STATUSES = new Set(["added", "deleted", "modified"])
21
+
22
+ const record = (v: unknown): Record<string, any> => (typeof v === "object" && v !== null ? (v as Record<string, any>) : {})
23
+ const count = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0)
24
+
25
+ export function contextUsage(messages: ReadonlyArray<unknown>, models: ReadonlyArray<unknown>): ContextUsage | undefined {
26
+ for (let i = messages.length - 1; i >= 0; i--) {
27
+ const message = record(messages[i])
28
+ if (message.type !== "assistant" || !message.tokens) continue
29
+ const t = record(message.tokens)
30
+ const used = count(t.input) + count(t.output) + count(t.reasoning) + count(record(t.cache).read) + count(record(t.cache).write)
31
+ const ref = record(message.model)
32
+ const info = record(models.find((m) => record(m).providerID === ref.providerID && record(m).modelID === ref.id))
33
+ const limit = count(record(info.limit).context)
34
+ return {
35
+ used,
36
+ ...(limit ? { limit, percent: Math.min(100, Math.max(0, Math.round((used / limit) * 100))) } : {}),
37
+ ...(typeof info.name === "string" ? { model: info.name } : {}),
38
+ }
39
+ }
40
+ return undefined
41
+ }
42
+
43
+ export function mcpServers(raw: ReadonlyArray<unknown>): McpStatus[] {
44
+ return raw
45
+ .flatMap((value) => {
46
+ const server = record(value)
47
+ if (typeof server.name !== "string" || !server.name) return []
48
+ const status = record(server.status)
49
+ const name = MCP_STATUSES.has(status.status) ? (status.status as McpStatus["status"]) : "unknown"
50
+ const error = typeof status.error === "string" && status.error ? status.error.slice(0, MAX_ERROR_CHARS) : undefined
51
+ return [{ name: server.name.slice(0, MAX_NAME_CHARS), status: name, ...(error ? { error } : {}) }]
52
+ })
53
+ .slice(0, MAX_MCP_SERVERS)
54
+ }
55
+
56
+ function changedFile(value: unknown): (ChangedFile & { patch: string }) | undefined {
57
+ const d = record(value)
58
+ if (typeof d.file !== "string" || !d.file) return undefined
59
+ return {
60
+ file: d.file,
61
+ status: FILE_STATUSES.has(d.status) ? d.status : "modified",
62
+ additions: count(d.additions),
63
+ deletions: count(d.deletions),
64
+ patch: typeof d.patch === "string" ? d.patch : "",
65
+ }
66
+ }
67
+
68
+ export function changeStats(raw: ReadonlyArray<unknown>): SessionChanges {
69
+ const all: ChangedFile[] = raw.flatMap((value) => {
70
+ const file = changedFile(value)
71
+ if (!file) return []
72
+ const { patch: _patch, ...stats } = file
73
+ return [stats]
74
+ })
75
+ const ranked = all.length > MAX_CHANGED_FILES ? [...all].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions) || a.file.localeCompare(b.file)) : all
76
+ const files: ChangedFile[] = []
77
+ let size = 0
78
+ for (const file of ranked) {
79
+ if (files.length >= MAX_CHANGED_FILES || (size += JSON.stringify(file).length + 1) > MAX_CHANGES_ENCODED_CHARS) break
80
+ files.push(file)
81
+ }
82
+ return {
83
+ files,
84
+ additions: all.reduce((n, f) => n + f.additions, 0),
85
+ deletions: all.reduce((n, f) => n + f.deletions, 0),
86
+ totalFiles: all.length,
87
+ truncated: files.length < all.length,
88
+ }
89
+ }
90
+
91
+ const isHigh = (c: number) => c >= 0xd800 && c <= 0xdbff
92
+ const isLow = (c: number) => c >= 0xdc00 && c <= 0xdfff
93
+ const SHORT_ESCAPES = new Set([0x08, 0x09, 0x0a, 0x0c, 0x0d, 0x22, 0x5c])
94
+
95
+ /** The longest prefix of `text` whose JSON encoding (with quotes) fits in `max` characters, never splitting a surrogate pair. */
96
+ export function clipEncoded(text: string, max: number): { text: string; truncated: boolean } {
97
+ let size = 2
98
+ for (let i = 0; i < text.length; i++) {
99
+ const c = text.charCodeAt(i)
100
+ // A well-formed surrogate pair is written as-is (two units); lone surrogates and other control characters as \uXXXX.
101
+ const pair = isHigh(c) && isLow(text.charCodeAt(i + 1))
102
+ const width = pair ? 2 : SHORT_ESCAPES.has(c) ? 2 : c < 0x20 || isHigh(c) || isLow(c) ? 6 : 1
103
+ if ((size += width) > max) return { text: text.slice(0, i), truncated: true }
104
+ if (pair) i++
105
+ }
106
+ return { text, truncated: false }
107
+ }
108
+
109
+ export function filePatch(raw: ReadonlyArray<unknown>, file: string): FilePatch | undefined {
110
+ for (const value of raw) {
111
+ const found = changedFile(value)
112
+ if (found?.file !== file) continue
113
+ const { text, truncated } = clipEncoded(found.patch, MAX_PATCH_CHARS)
114
+ return { ...found, patch: text, truncated }
115
+ }
116
+ return undefined
117
+ }
@@ -0,0 +1,93 @@
1
+ import { PROMPT_IMAGE_MIMES, type PromptImage } from "../protocol"
2
+
3
+ /** An attachment of a user message: its position in the message's files (for timelineFile), type and name. */
4
+ export interface TimelineFile {
5
+ readonly index: number
6
+ readonly mime: string
7
+ readonly name?: string
8
+ }
9
+
10
+ export interface TimelineItem {
11
+ readonly key: string
12
+ readonly messageID: string
13
+ readonly partID?: string
14
+ readonly sessionID: string
15
+ readonly kind: "user" | "assistant" | "reasoning" | "tool"
16
+ readonly text?: string
17
+ readonly name?: string
18
+ readonly state?: "running" | "done" | "failed"
19
+ readonly at?: number
20
+ readonly completed?: number
21
+ readonly files?: readonly TimelineFile[]
22
+ }
23
+
24
+ const record = (value: unknown): Record<string, any> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {}
25
+
26
+ /** Projects OpenCode messages into stable, ordered, payload-light phone timeline rows. */
27
+ export function timelineItems(sessionID: string, messages: readonly unknown[]): TimelineItem[] {
28
+ const out: TimelineItem[] = []
29
+ for (const raw of messages) {
30
+ const message = record(raw)
31
+ if (typeof message.id !== "string") continue
32
+ const at = typeof message.time?.created === "number" ? message.time.created : undefined
33
+ if (message.type === "user") {
34
+ const text = typeof message.text === "string" ? message.text : Array.isArray(message.content) ? message.content.filter((p: any) => p?.type === "text").map((p: any) => p.text).join("") : ""
35
+ const files = userFiles(message.files)
36
+ if (text || files.length) out.push({ key: `${message.id}:text`, messageID: message.id, sessionID, kind: "user", text, ...(at === undefined ? {} : { at }), ...(files.length ? { files } : {}) })
37
+ continue
38
+ }
39
+ if (message.type !== "assistant" || !Array.isArray(message.content)) continue
40
+ message.content.forEach((part: any, index: number) => {
41
+ if (!part || typeof part !== "object") return
42
+ const partID = String(index)
43
+ const base = { key: `${message.id}:${partID}`, messageID: message.id, partID, sessionID, ...(at === undefined ? {} : { at }) }
44
+ if (part.type === "text" && typeof part.text === "string" && part.text) out.push({ ...base, kind: "assistant", text: part.text })
45
+ if (part.type === "reasoning") out.push({ ...base, kind: "reasoning", text: String(part.text ?? ""), state: part.time?.completed === undefined ? "running" : "done", ...(typeof part.time?.created === "number" ? { at: part.time.created } : {}), ...(typeof part.time?.completed === "number" ? { completed: part.time.completed } : {}) })
46
+ if (part.type === "tool") {
47
+ const status = part.state?.status
48
+ out.push({ ...base, kind: "tool", name: String(part.name ?? "tool"), state: status === "error" ? "failed" : status === "completed" ? "done" : "running", ...(typeof part.time?.created === "number" ? { at: part.time.created } : {}), ...(typeof part.time?.completed === "number" ? { completed: part.time.completed } : {}) })
49
+ }
50
+ })
51
+ }
52
+ return out
53
+ }
54
+
55
+ /** Attachment metadata (never the bytes) of a user message's files. */
56
+ function userFiles(raw: unknown): TimelineFile[] {
57
+ if (!Array.isArray(raw)) return []
58
+ return raw.flatMap((file, index) => {
59
+ const f = record(file)
60
+ if (typeof f.mime !== "string") return []
61
+ return [{ index, mime: f.mime, ...(typeof f.name === "string" && f.name ? { name: f.name.slice(0, 200) } : {}) }]
62
+ })
63
+ }
64
+
65
+ /** Largest attachment served (base64 characters), so the answer fits in one frame. */
66
+ export const TIMELINE_FILE_MAX_BASE64 = 900_000
67
+
68
+ /**
69
+ * The image at `index` of a user message: only the prompt image types are served (never SVG or other
70
+ * files); nothing when there is no such image; `tooLarge` when its bytes would not fit in a frame.
71
+ */
72
+ export function timelineFile(messages: readonly unknown[], messageID: string, index: number): { file?: PromptImage; tooLarge?: boolean } {
73
+ const message = messages.map(record).find((item) => item.id === messageID && item.type === "user")
74
+ const file = Array.isArray(message?.files) ? record(message.files[index]) : {}
75
+ if (typeof file.mime !== "string" || !PROMPT_IMAGE_MIMES.includes(file.mime) || typeof file.data !== "string" || !file.data) return {}
76
+ if (file.data.length > TIMELINE_FILE_MAX_BASE64) return { tooLarge: true }
77
+ return { file: { mime: file.mime, data: file.data, ...(typeof file.name === "string" && file.name ? { name: file.name.slice(0, 200) } : {}) } }
78
+ }
79
+
80
+ export function timelinePart(messages: readonly unknown[], messageID: string, partID: string): unknown {
81
+ const message = messages.map(record).find((item) => item.id === messageID)
82
+ const index = Number(partID)
83
+ const part = Number.isInteger(index) && Array.isArray(message?.content) ? message.content[index] : undefined
84
+ if (!part || part.type !== "tool") throw new Error("Timeline part not found")
85
+ return { name: part.name, state: structuredClone(part.state), time: structuredClone(part.time) }
86
+ }
87
+
88
+ const DETAIL_MAX_BYTES = 750_000
89
+ export function boundedTimelinePart(messages: readonly unknown[], messageID: string, partID: string): unknown {
90
+ const detail = timelinePart(messages, messageID, partID)
91
+ if (JSON.stringify(detail).length > DETAIL_MAX_BYTES) throw new Error("Timeline part is too large to display")
92
+ return detail
93
+ }
@@ -0,0 +1,345 @@
1
+ // Sessions this OpenCode process knows about, built from the events it sees, plus which of them
2
+ // are exposed to the central server (by mode). Pure: time is passed in.
3
+ import type { Mode } from "../config"
4
+ import { FORWARDED_EVENTS, type SessionInfo, type TerminalInfo } from "../protocol"
5
+
6
+ /** A session is "owned" by this process while it viewed or ran it within this window. */
7
+ export const OWNED_WINDOW_MS = 30 * 60 * 1000
8
+
9
+ export interface EventLike {
10
+ readonly type: string
11
+ readonly data?: unknown
12
+ readonly created?: number
13
+ }
14
+
15
+ export interface SessionDetails {
16
+ readonly title: string
17
+ readonly directory: string
18
+ readonly parentID?: string
19
+ readonly updated: number
20
+ /** Workspace root and name (from the service's listing). */
21
+ readonly project?: string
22
+ readonly projectName?: string
23
+ }
24
+
25
+ interface Entry {
26
+ id: string
27
+ title: string
28
+ directory: string
29
+ parentID?: string
30
+ updated: number
31
+ lastActivity?: number
32
+ /** Title/directory are known (from session.created, a lookup, or the listing). */
33
+ info: boolean
34
+ project?: string
35
+ projectName?: string
36
+ /** Came from the service's listing of recent sessions (dropped when it leaves the listing). */
37
+ listed?: boolean
38
+ }
39
+
40
+ const ACTIVITY = new Set([
41
+ "session.viewed",
42
+ "session.execution.started",
43
+ "session.execution.succeeded",
44
+ "session.execution.failed",
45
+ "session.execution.interrupted",
46
+ "session.inbox.delivered",
47
+ "permission.asked",
48
+ ])
49
+
50
+ const record = (v: unknown): Record<string, any> => (typeof v === "object" && v !== null ? (v as Record<string, any>) : {})
51
+
52
+ /** The session an event belongs to, if any. */
53
+ export function eventSessionID(event: EventLike): string | undefined {
54
+ const data = record(event.data)
55
+ if (typeof data.sessionID === "string" && data.sessionID) return data.sessionID
56
+ const form = record(data.form)
57
+ if (typeof form.sessionID === "string" && form.sessionID) return form.sessionID
58
+ return undefined
59
+ }
60
+
61
+ const RUN_END = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])
62
+
63
+ export class SessionTracker {
64
+ private readonly entries = new Map<string, Entry>()
65
+ /** Sessions with a run in progress. */
66
+ private readonly running = new Set<string>()
67
+ /** Pending permission or form ID → its session. */
68
+ private readonly waiting = new Map<string, string>()
69
+
70
+ /** Applies an event seen in this process. Returns true when the snapshot may have changed. */
71
+ observe(event: EventLike, now: number): boolean {
72
+ this.trackActive(event)
73
+ const data = record(event.data)
74
+ const id = typeof data.sessionID === "string" && data.sessionID ? data.sessionID : undefined
75
+ if (!id) return false
76
+ const at = typeof event.created === "number" ? event.created : now
77
+
78
+ switch (event.type) {
79
+ case "session.created": {
80
+ const entry = this.entry(id, at)
81
+ entry.title = typeof data.title === "string" ? data.title : entry.title
82
+ entry.directory = typeof data.location?.directory === "string" ? data.location.directory : entry.directory
83
+ if (typeof data.parentID === "string" && data.parentID) entry.parentID = data.parentID
84
+ entry.info = true
85
+ entry.lastActivity = now
86
+ return true
87
+ }
88
+ case "session.renamed": {
89
+ const entry = this.entries.get(id)
90
+ if (!entry || typeof data.title !== "string") return false
91
+ entry.title = data.title
92
+ entry.updated = Math.max(entry.updated, at)
93
+ return true
94
+ }
95
+ case "session.deleted":
96
+ this.running.delete(id)
97
+ for (const [key, sessionID] of this.waiting) if (sessionID === id) this.waiting.delete(key)
98
+ return this.entries.delete(id)
99
+ default: {
100
+ if (!ACTIVITY.has(event.type)) return false
101
+ const entry = this.entry(id, at)
102
+ entry.updated = Math.max(entry.updated, at)
103
+ entry.lastActivity = now
104
+ return true
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Applies a full listing of the service's recent root sessions. Sessions known only from listings
111
+ * follow it (renames, removal); live sessions keep their live details and only gain the workspace.
112
+ * Returns true when the snapshot may have changed.
113
+ */
114
+ applyListing(sessions: ReadonlyArray<{ readonly id: string } & SessionDetails>, now: number, keep: ReadonlySet<string> = new Set()): boolean {
115
+ let changed = false
116
+ const ids = new Set(sessions.map((s) => s.id))
117
+ for (const entry of [...this.entries.values()]) {
118
+ if (!entry.listed || ids.has(entry.id) || keep.has(entry.id)) continue
119
+ if (entry.lastActivity !== undefined && now - entry.lastActivity <= OWNED_WINDOW_MS) {
120
+ entry.listed = false
121
+ continue
122
+ }
123
+ this.entries.delete(entry.id)
124
+ changed = true
125
+ }
126
+ for (const s of sessions) {
127
+ const existing = this.entries.get(s.id)
128
+ if (!existing) {
129
+ this.entries.set(s.id, {
130
+ id: s.id,
131
+ title: s.title,
132
+ directory: s.directory,
133
+ ...(s.parentID ? { parentID: s.parentID } : {}),
134
+ updated: s.updated,
135
+ info: true,
136
+ listed: true,
137
+ ...workspace(s),
138
+ })
139
+ changed = true
140
+ continue
141
+ }
142
+ const listingOnly = existing.info && existing.lastActivity === undefined
143
+ const next = listingOnly
144
+ ? { title: s.title, directory: s.directory, updated: s.updated }
145
+ : { updated: Math.max(existing.updated, s.updated) }
146
+ const update = { ...next, ...workspace(s), listed: true }
147
+ for (const [key, value] of Object.entries(update) as Array<[keyof Entry, never]>) {
148
+ if (existing[key] === value) continue
149
+ existing[key] = value
150
+ changed = true
151
+ }
152
+ }
153
+ return changed
154
+ }
155
+
156
+ /** Fills in details fetched for a session that was first seen through an activity event. */
157
+ setInfo(id: string, details: SessionDetails): boolean {
158
+ const entry = this.entries.get(id)
159
+ if (!entry) return false
160
+ entry.title = details.title
161
+ entry.directory = details.directory
162
+ if (details.parentID) entry.parentID = details.parentID
163
+ else delete entry.parentID
164
+ if (details.project) entry.project = details.project
165
+ if (details.projectName) entry.projectName = details.projectName
166
+ entry.updated = Math.max(entry.updated, details.updated)
167
+ entry.info = true
168
+ return true
169
+ }
170
+
171
+ /**
172
+ * Adds a session known only by ID (e.g. open in a terminal), not owned; its details (and last update)
173
+ * are fetched later. `_now` is accepted for symmetry with observe.
174
+ */
175
+ ensure(id: string, _now: number): boolean {
176
+ if (this.entries.has(id)) return false
177
+ this.entry(id, 0)
178
+ return true
179
+ }
180
+
181
+ /** Root sessions with a run in progress or a pending permission or form somewhere in their tree. */
182
+ active(): string[] {
183
+ const roots = new Set<string>()
184
+ for (const id of [...this.running, ...this.waiting.values()]) roots.add(this.rootOf(id))
185
+ return [...roots]
186
+ }
187
+
188
+ private trackActive(event: EventLike): void {
189
+ const data = record(event.data)
190
+ const sessionID = eventSessionID(event)
191
+ if (event.type === "session.execution.started" && sessionID) this.running.add(sessionID)
192
+ else if (RUN_END.has(event.type) && sessionID) this.running.delete(sessionID)
193
+ else if (event.type === "permission.asked" && sessionID && typeof data.id === "string") this.waiting.set(`per ${data.id}`, sessionID)
194
+ else if (event.type === "permission.replied" && typeof data.requestID === "string") this.waiting.delete(`per ${data.requestID}`)
195
+ else if (event.type === "form.created" && sessionID && typeof record(data.form).id === "string") this.waiting.set(`frm ${record(data.form).id}`, sessionID)
196
+ else if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof (record(data.form).id ?? data.formID ?? data.id) === "string") {
197
+ this.waiting.delete(`frm ${record(data.form).id ?? data.formID ?? data.id}`)
198
+ }
199
+ }
200
+
201
+ /** A form (question) this process saw asked and not yet replied or cancelled. */
202
+ formPending(formID: string): boolean {
203
+ return this.waiting.has(`frm ${formID}`)
204
+ }
205
+
206
+ /** Running sessions strictly below a root, deepest first. */
207
+ runningBelow(root: string): string[] {
208
+ return [...this.running]
209
+ .filter((id) => id !== root && this.lineage(id).includes(root))
210
+ .sort((a, b) => this.lineage(b).length - this.lineage(a).length)
211
+ }
212
+
213
+ needsInfo(): string[] {
214
+ return [...this.entries.values()].filter((e) => !e.info).map((e) => e.id)
215
+ }
216
+
217
+ has(id: string): boolean {
218
+ return this.entries.has(id)
219
+ }
220
+
221
+ directoryOf(id: string): string | undefined {
222
+ return this.entries.get(id)?.directory || undefined
223
+ }
224
+
225
+ parentOf(id: string): string | undefined {
226
+ return this.entries.get(id)?.parentID
227
+ }
228
+
229
+ /** The top-most known ancestor of a session (itself when it has no known parent). */
230
+ rootOf(id: string): string {
231
+ let current = id
232
+ const seen = new Set<string>([id])
233
+ for (;;) {
234
+ const parent = this.parentOf(current)
235
+ if (!parent || seen.has(parent)) return current
236
+ seen.add(parent)
237
+ current = parent
238
+ }
239
+ }
240
+
241
+ /** Lineage from the session up to its root, cycle-safe. */
242
+ lineage(id: string): string[] {
243
+ const out = [id]
244
+ let current = id
245
+ for (;;) {
246
+ const parent = this.parentOf(current)
247
+ if (!parent || out.includes(parent)) return out
248
+ out.push(parent)
249
+ current = parent
250
+ }
251
+ }
252
+
253
+ snapshot(now: number): SessionInfo[] {
254
+ return [...this.entries.values()]
255
+ .filter((e) => e.info)
256
+ .map((e) => ({
257
+ id: e.id,
258
+ title: e.title,
259
+ directory: e.directory,
260
+ ...(e.parentID ? { parentID: e.parentID } : {}),
261
+ updated: e.updated,
262
+ owned: e.lastActivity !== undefined && now - e.lastActivity <= OWNED_WINDOW_MS,
263
+ ...(e.project ? { project: e.project } : {}),
264
+ ...(e.projectName ? { projectName: e.projectName } : {}),
265
+ }))
266
+ }
267
+
268
+ private entry(id: string, at: number): Entry {
269
+ let entry = this.entries.get(id)
270
+ if (!entry) {
271
+ entry = { id, title: "", directory: "", updated: at, info: false }
272
+ this.entries.set(id, entry)
273
+ }
274
+ return entry
275
+ }
276
+ }
277
+
278
+ const workspace = (s: SessionDetails) => ({
279
+ ...(s.project ? { project: s.project } : {}),
280
+ ...(s.projectName ? { projectName: s.projectName } : {}),
281
+ })
282
+
283
+ export interface ExposureConfig {
284
+ readonly mode: Mode
285
+ readonly sessions: readonly string[]
286
+ }
287
+
288
+ /** off: nothing; always: everything; session: listed sessions and their descendants. */
289
+ export function isExposed(config: ExposureConfig, tracker: SessionTracker, sessionID: string): boolean {
290
+ if (config.mode === "always") return true
291
+ if (config.mode !== "session") return false
292
+ return tracker.lineage(sessionID).some((id) => config.sessions.includes(id))
293
+ }
294
+
295
+ /** Remote inspection and control: any session this process knows while connected (mode not "off"). Voice is separate. */
296
+ export function isReported(config: ExposureConfig, tracker: SessionTracker, sessionID: string): boolean {
297
+ return config.mode !== "off" && (tracker.has(sessionID) || isExposed(config, tracker, sessionID))
298
+ }
299
+
300
+ export function shouldForward(config: ExposureConfig, tracker: SessionTracker, event: EventLike): boolean {
301
+ if (!FORWARDED_EVENTS.has(event.type)) return false
302
+ const sessionID = eventSessionID(event)
303
+ return sessionID !== undefined && isReported(config, tracker, sessionID)
304
+ }
305
+
306
+ /** Most sessions in one snapshot frame, and its size budget (the protocol refuses frames over 1 MB). */
307
+ export const SNAPSHOT_MAX = 500
308
+ export const SNAPSHOT_MAX_BYTES = 900_000
309
+
310
+ /**
311
+ * The snapshot for the session browser: every known session with its Voice exposure. Over the limits,
312
+ * open, then exposed, then most recent sessions are kept.
313
+ */
314
+ export function browserSessions(
315
+ config: ExposureConfig,
316
+ tracker: SessionTracker,
317
+ now: number,
318
+ limits: {
319
+ readonly max?: number
320
+ readonly maxBytes?: number
321
+ /** Where a session is open right now (Herdr pane or TUI), if it is. */
322
+ readonly terminal?: (sessionID: string) => TerminalInfo | undefined
323
+ } = {},
324
+ ): Array<SessionInfo & { exposed: boolean }> {
325
+ if (config.mode === "off") return []
326
+ const all = tracker
327
+ .snapshot(now)
328
+ .map((s) => {
329
+ const terminal = limits.terminal?.(s.id)
330
+ return { ...s, exposed: isExposed(config, tracker, s.id), ...(terminal ? { open: true, terminal } : {}) }
331
+ })
332
+ const max = limits.max ?? SNAPSHOT_MAX
333
+ const maxBytes = limits.maxBytes ?? SNAPSHOT_MAX_BYTES
334
+ if (all.length <= max && JSON.stringify(all).length <= maxBytes) return all
335
+ const ranked = [...all].sort((a, b) => Number(b.open === true) - Number(a.open === true) || Number(b.exposed) - Number(a.exposed) || b.updated - a.updated)
336
+ const keep = new Set<string>()
337
+ let bytes = 2
338
+ for (const s of ranked) {
339
+ const size = JSON.stringify(s).length + 1
340
+ if (keep.size >= max || bytes + size > maxBytes) break
341
+ bytes += size
342
+ keep.add(s.id)
343
+ }
344
+ return all.filter((s) => keep.has(s.id))
345
+ }
@@ -0,0 +1,78 @@
1
+ // Keeps the per-machine config live: fs.watch on the config's directory (atomic writes replace
2
+ // the file, so watching the file itself would go stale) plus a slow poll as a fallback.
3
+ import { watch, type FSWatcher } from "node:fs"
4
+ import path from "node:path"
5
+ import { readConfig, type VoiceConfig } from "../config"
6
+
7
+ export interface ConfigWatcher {
8
+ current(): VoiceConfig
9
+ /** Re-reads now (e.g. after this process wrote the file) and notifies when it changed. */
10
+ refresh(): VoiceConfig
11
+ stop(): void
12
+ }
13
+
14
+ export function watchConfig(
15
+ file: string,
16
+ machineName: string,
17
+ onChange: (config: VoiceConfig) => void,
18
+ options: { pollMs?: number } = {},
19
+ ): ConfigWatcher {
20
+ let config = readConfig(file, machineName)
21
+ let key = JSON.stringify(config)
22
+ let stopped = false
23
+ let watcher: FSWatcher | undefined
24
+ let debounce: ReturnType<typeof setTimeout> | undefined
25
+
26
+ const refresh = () => {
27
+ if (stopped) return config
28
+ const next = readConfig(file, machineName)
29
+ const nextKey = JSON.stringify(next)
30
+ if (nextKey !== key) {
31
+ config = next
32
+ key = nextKey
33
+ try {
34
+ onChange(config)
35
+ } catch (cause) {
36
+ console.error("[hub] config change handler failed", cause)
37
+ }
38
+ }
39
+ return config
40
+ }
41
+
42
+ const startWatcher = () => {
43
+ if (watcher || stopped) return
44
+ try {
45
+ const base = path.basename(file)
46
+ watcher = watch(path.dirname(file), { persistent: false }, (_event, name) => {
47
+ if (name && name !== base) return
48
+ clearTimeout(debounce)
49
+ debounce = setTimeout(refresh, 50)
50
+ })
51
+ watcher.on("error", () => {
52
+ watcher?.close()
53
+ watcher = undefined
54
+ })
55
+ } catch {
56
+ watcher = undefined // directory missing; the poll keeps trying
57
+ }
58
+ }
59
+
60
+ startWatcher()
61
+ const poll = setInterval(() => {
62
+ startWatcher()
63
+ refresh()
64
+ }, options.pollMs ?? 5000)
65
+ ;(poll as { unref?: () => void }).unref?.()
66
+
67
+ return {
68
+ current: () => config,
69
+ refresh,
70
+ stop: () => {
71
+ stopped = true
72
+ clearInterval(poll)
73
+ clearTimeout(debounce)
74
+ watcher?.close()
75
+ watcher = undefined
76
+ },
77
+ }
78
+ }