@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,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
|
+
}
|