@huynle/hub 0.8.2 → 0.8.4
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 +118 -0
- package/README.md +31 -0
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +35 -7
- package/packages/cli/package.json +1 -1
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +16 -3
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/routes.ts +7 -1
- package/src/central/artifacts/blob-store.ts +6 -1
- package/src/central/artifacts/blob-support.ts +3 -1
- package/src/central/concierge/board-render.ts +34 -11
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +85 -9
- package/src/central/profiles/content.ts +16 -0
- package/src/central/recording/clock.ts +76 -0
- package/src/central/recording/concierge-taps.ts +104 -0
- package/src/central/recording/export.ts +215 -0
- package/src/central/recording/index.ts +658 -0
- package/src/central/recording/ingest.ts +209 -0
- package/src/central/recording/retention.ts +71 -0
- package/src/central/recording/routes.ts +281 -0
- package/src/central/recording/schema.ts +232 -0
- package/src/central/recording/settings.ts +35 -0
- package/src/central/recording/store.ts +316 -0
- package/src/central/recording/taps.ts +105 -0
- package/src/central/recording/zip.ts +138 -0
- package/src/central/registry.ts +161 -25
- package/src/central/spoke-ws.ts +2 -0
- package/src/index.ts +28 -0
- package/src/spoke/session-context.ts +28 -8
- package/src/spoke/spoke.ts +153 -10
- package/src/spoke/tracker.ts +6 -1
- package/src/status.ts +101 -19
- package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-Cb1DcrQf.js} +1 -1
- package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-XTnwXl-Y.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-C37tZZ3e.js} +1 -1
- package/web/dist/assets/{Library-BXbJvTUS.js → Library-CZSSHvOJ.js} +2 -2
- package/web/dist/assets/ProfilesDestination-dZa3vioh.js +1 -0
- package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-DJt-Kk84.js} +1 -1
- package/web/dist/assets/Watchtower-BXaUaXO1.js +2 -0
- package/web/dist/assets/index-DVSkeAvn.css +1 -0
- package/web/dist/assets/index-DifgVuKS.js +34 -0
- package/web/dist/assets/{live-OgQWPrSY.js → live-D-RZbeUi.js} +1 -1
- package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
- package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-DILxLFEq.js} +1 -1
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-PvN9Qbt2.js} +4 -56
- package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-BS6ii3M7.js} +1 -1
- package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
- package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
- package/web/dist/index.html +5 -16
- package/web/dist/theme-boot.js +12 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
- package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- package/web/dist/assets/index-g4V9mfSr.js +0 -34
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// a session's per-turn context; the spoke keeps it in this process's memory (like profile layers, profile-apply.ts):
|
|
3
3
|
//
|
|
4
4
|
// - parts: instructions then notes are appended by the plugin's context hook after the profile parts; the board goes
|
|
5
|
-
// after the conversation
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// after the conversation (V-01: it changes between turns, and OpenCode sends tools, system, then messages, so a
|
|
6
|
+
// board in the system prompt made every push re-write the cached conversation; see applySessionContext) as
|
|
7
|
+
// user-side <system-update> text (V-01b: OmniRoute hoists a system message after the conversation back into the
|
|
8
|
+
// system prompt). The whole set replaces the previous one.
|
|
8
9
|
// - The kept tools are put in name order (the request lists them in the hook's record order).
|
|
9
10
|
// - tools.allow: the exact tool names the session keeps; the hook deletes every other tool on every turn (unknown
|
|
10
11
|
// future tools included: fail closed). tools.deny: globs of tool names deleted as well.
|
|
@@ -73,22 +74,41 @@ function hint(message: HookMessage): void {
|
|
|
73
74
|
if (part && part.cache === undefined) message.content[index] = { ...part, cache: EPHEMERAL }
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/** (V-01b) The board as user-side text: in <system-update> tags, its markup escaped so no text can close them. */
|
|
78
|
+
export function boardUpdate(tail: readonly string[]): string {
|
|
79
|
+
const text = tail
|
|
80
|
+
.join("\n\n")
|
|
81
|
+
.replace(/[\ud800-\udfff]/gu, "") // lone surrogates (a strict provider rejects the request)
|
|
82
|
+
.replace(/&/g, "&")
|
|
83
|
+
.replace(/</g, "<")
|
|
84
|
+
.replace(/>/g, ">")
|
|
85
|
+
return `<system-update>\n${text}\n</system-update>`
|
|
86
|
+
}
|
|
87
|
+
|
|
76
88
|
/**
|
|
77
89
|
* (V-01) Adds a session context to a context hook's request so the provider's prompt cache survives a board change.
|
|
78
90
|
* OpenCode sends tools, the system parts, then the messages; it puts cache breakpoints on the last tool, the first and
|
|
79
91
|
* last system part and the last message (4 at most). The stable parts end the system prompt (the last one carries a
|
|
80
|
-
* hint); the board follows the conversation as one
|
|
92
|
+
* hint); the board follows the conversation as one user message (with a hint), and the conversation's last message
|
|
81
93
|
* gets a hint, so the next turn reads everything up to it from the cache whatever the board says. Without a board
|
|
82
|
-
* nothing is added after the conversation; a request without messages gets the
|
|
94
|
+
* (or an empty one) nothing is added after the conversation; a request without messages gets the same <system-update>
|
|
95
|
+
* text at the end of its system prompt.
|
|
96
|
+
*
|
|
97
|
+
* (V-01b) The board is user-side text, never a system message: on the OmniRoute route (claude/claude-sonnet-5-5,
|
|
98
|
+
* measured through a logging proxy) a system message after the conversation is hoisted into the top-level system
|
|
99
|
+
* prompt, so a changed board invalidated the whole cached conversation again. The prompt's instructions say the
|
|
100
|
+
* board arrives in <system-update> tags and is never the user's words; boardUpdate escapes its markup.
|
|
83
101
|
*/
|
|
84
102
|
export function applySessionContext(event: { system: HookPart[]; messages?: HookMessage[] }, system: readonly string[], tail: readonly string[]): void {
|
|
85
103
|
const messages = Array.isArray(event.messages) ? event.messages : undefined
|
|
86
|
-
|
|
104
|
+
// (V-01b verify) An empty board adds nothing; a board is always the escaped <system-update> text, never raw.
|
|
105
|
+
const board = tail.filter((text) => text.trim() !== "")
|
|
106
|
+
const ending = messages || !board.length ? system : [...system, boardUpdate(board)]
|
|
87
107
|
ending.forEach((text, i) => event.system.push(i === ending.length - 1 ? { type: "text", text, cache: EPHEMERAL } : { type: "text", text }))
|
|
88
|
-
if (!messages || !
|
|
108
|
+
if (!messages || !board.length) return
|
|
89
109
|
const last = messages[lastIndex(messages, (m) => !isEffort(m))]
|
|
90
110
|
if (last) hint(last)
|
|
91
|
-
messages.push({ role: "
|
|
111
|
+
messages.push({ role: "user", content: [{ type: "text", text: boardUpdate(board), cache: EPHEMERAL }] })
|
|
92
112
|
}
|
|
93
113
|
|
|
94
114
|
/** Locked sessions kept at most (oldest dropped; the hub keeps 20 Concierge sessions per user). */
|
package/src/spoke/spoke.ts
CHANGED
|
@@ -141,6 +141,12 @@ export interface SpokeHost {
|
|
|
141
141
|
* were already running when this process started, or while its event stream was down.
|
|
142
142
|
*/
|
|
143
143
|
activity?(directories: readonly string[]): Promise<{ readonly active: readonly string[]; readonly jobs: ReadonlyArray<JobInfo & { readonly sessionID: string }> }>
|
|
144
|
+
/**
|
|
145
|
+
* What OpenCode waits for in each of the sessions, from this process's own server: pending forms (FormInfo, as in
|
|
146
|
+
* form.created) and permission requests (as in permission.asked). A session it could not read is left out.
|
|
147
|
+
* Absent, or empty when the server cannot be reached: only what this process saw asked is re-announced.
|
|
148
|
+
*/
|
|
149
|
+
pending?(sessionIDs: readonly string[]): Promise<ReadonlyArray<PendingAsks>>
|
|
144
150
|
/**
|
|
145
151
|
* Recent root sessions with their workspace: those in the history window (`days`, at most `max`), plus `include`
|
|
146
152
|
* (always listed: open, running, ...). The background service lists every session; a foreground `opencode serve`
|
|
@@ -177,6 +183,13 @@ export interface SpokeHost {
|
|
|
177
183
|
revertCommit?(sessionID: string): Promise<unknown>
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
/** A session's pending forms and permission requests as OpenCode lists them (raw, see SpokeHost.pending). */
|
|
187
|
+
export interface PendingAsks {
|
|
188
|
+
readonly sessionID: string
|
|
189
|
+
readonly forms: readonly unknown[]
|
|
190
|
+
readonly permissions: readonly unknown[]
|
|
191
|
+
}
|
|
192
|
+
|
|
180
193
|
/** A form reply for OpenCode: an answer, or a cancel. */
|
|
181
194
|
export type FormAction =
|
|
182
195
|
| { readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer }
|
|
@@ -205,6 +218,8 @@ export interface SpokeOptions {
|
|
|
205
218
|
readonly forkNoteTimeoutMs?: number
|
|
206
219
|
readonly snapshotIntervalMs?: number
|
|
207
220
|
readonly seedIntervalMs?: number
|
|
221
|
+
/** Longest the own server may take to list a seed's pending forms and permissions (default 5 s). */
|
|
222
|
+
readonly pendingTimeoutMs?: number
|
|
208
223
|
/** Delay before re-listing the service's sessions after a session event (default 2 s). */
|
|
209
224
|
readonly seedDebounceMs?: number
|
|
210
225
|
/** Longest one session listing may take before it is abandoned and later listings run (default 60 s). */
|
|
@@ -389,6 +404,15 @@ export class Spoke {
|
|
|
389
404
|
private started = false
|
|
390
405
|
/** A seedActivity call is in flight. */
|
|
391
406
|
private seeding = false
|
|
407
|
+
/**
|
|
408
|
+
* Pending permissions and forms (`per <id>` / `frm <id>`) with the event that asked them, as forwarded: the hub
|
|
409
|
+
* keeps them only as long as this connection, so they are announced again after a reconnect (see reannounce).
|
|
410
|
+
* `seq` orders them against a pending listing (one asked while OpenCode was being asked is never closed by it).
|
|
411
|
+
*/
|
|
412
|
+
private readonly asks = new Map<string, { sessionID: string; event: { type: string; data: Record<string, unknown> }; seq: number }>()
|
|
413
|
+
private askSeq = 0
|
|
414
|
+
/** Asks seen answered or cancelled (an ID never comes back): a listing taken before the reply cannot revive them. */
|
|
415
|
+
private readonly closedAsks = new Set<string>()
|
|
392
416
|
/** Sessions shown by OpenCode TUIs (voice.presence). */
|
|
393
417
|
private readonly presenceBook = new PresenceBook()
|
|
394
418
|
/** Form replies waiting for a TUI to carry them out (when the plugin cannot reach its own server). */
|
|
@@ -907,6 +931,9 @@ export class Spoke {
|
|
|
907
931
|
onWelcome: () => {
|
|
908
932
|
this.sendSnapshot()
|
|
909
933
|
this.sendTerminals(true)
|
|
934
|
+
// A new connection: the hub dropped what was pending with the old one (or restarted). What this process
|
|
935
|
+
// remembers goes out again at once; the seed then asks OpenCode what it still waits for.
|
|
936
|
+
this.reannounce()
|
|
910
937
|
void this.seedActivity()
|
|
911
938
|
},
|
|
912
939
|
onState: (state, error) => {
|
|
@@ -1135,6 +1162,9 @@ export class Spoke {
|
|
|
1135
1162
|
return {}
|
|
1136
1163
|
case "rename":
|
|
1137
1164
|
return this.rename(req.sessionID, req.title)
|
|
1165
|
+
case "dismissError":
|
|
1166
|
+
this.unheardFailures.delete(req.sessionID)
|
|
1167
|
+
return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
|
|
1138
1168
|
case "autoName":
|
|
1139
1169
|
return this.autoName(req.sessionID)
|
|
1140
1170
|
case "stop":
|
|
@@ -1781,6 +1811,7 @@ export class Spoke {
|
|
|
1781
1811
|
const title = (event.data as Record<string, unknown>).title
|
|
1782
1812
|
if (typeof title === "string") for (const waiter of this.renameWaiters.get(sessionID) ?? []) waiter(title)
|
|
1783
1813
|
}
|
|
1814
|
+
this.trackAsk(event)
|
|
1784
1815
|
if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
|
|
1785
1816
|
if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
|
|
1786
1817
|
if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
|
|
@@ -1788,21 +1819,123 @@ export class Spoke {
|
|
|
1788
1819
|
this.scheduleSeed()
|
|
1789
1820
|
}
|
|
1790
1821
|
void this.fetchInfo()
|
|
1791
|
-
|
|
1792
|
-
// Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
|
|
1793
|
-
// get their session from the job.
|
|
1794
|
-
const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
|
|
1795
|
-
if (compact && shouldForward(this.config, this.tracker, compact)) {
|
|
1796
|
-
this.announce(compact)
|
|
1797
|
-
this.connection.send({ t: "event", event: compact })
|
|
1798
|
-
if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1822
|
+
this.forward(event)
|
|
1801
1823
|
} catch (cause) {
|
|
1802
1824
|
console.error("[hub] event handling failed", cause)
|
|
1803
1825
|
}
|
|
1804
1826
|
}
|
|
1805
1827
|
|
|
1828
|
+
/** Sends an OpenCode event to the hub (compact), when connected and the hub may see its session. */
|
|
1829
|
+
private forward(event: EventLike): void {
|
|
1830
|
+
if (this.connection?.state !== "connected" || (!FORWARDED_EVENTS.has(event.type) && !COMPACTED_EVENTS.has(event.type))) return
|
|
1831
|
+
// Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
|
|
1832
|
+
// get their session from the job.
|
|
1833
|
+
const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
|
|
1834
|
+
if (!compact || !shouldForward(this.config, this.tracker, compact)) return
|
|
1835
|
+
this.announce(compact)
|
|
1836
|
+
this.connection.send({ t: "event", event: compact })
|
|
1837
|
+
const sessionID = (event.data as Record<string, unknown> | undefined)?.sessionID
|
|
1838
|
+
if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/** Keeps `asks` current: a permission or form asked, replied, cancelled, or its session deleted. */
|
|
1842
|
+
private trackAsk(event: EventLike): void {
|
|
1843
|
+
const data = (event.data ?? {}) as Record<string, any>
|
|
1844
|
+
if (event.type === "permission.asked" && typeof data.id === "string" && typeof data.sessionID === "string") {
|
|
1845
|
+
this.rememberAsk(`per ${data.id}`, data.sessionID, event)
|
|
1846
|
+
} else if (event.type === "form.created" && typeof data.form?.id === "string" && typeof data.form?.sessionID === "string") {
|
|
1847
|
+
this.rememberAsk(`frm ${data.form.id}`, data.form.sessionID, event)
|
|
1848
|
+
} else if (event.type === "permission.replied" && typeof data.requestID === "string") this.closeAsk(`per ${data.requestID}`)
|
|
1849
|
+
else if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string") this.closeAsk(`frm ${data.id}`)
|
|
1850
|
+
else if (event.type === "session.deleted" && typeof data.sessionID === "string") {
|
|
1851
|
+
for (const [key, ask] of this.asks) if (ask.sessionID === data.sessionID) this.asks.delete(key)
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
private closeAsk(key: string): void {
|
|
1856
|
+
this.asks.delete(key)
|
|
1857
|
+
this.closedAsks.add(key)
|
|
1858
|
+
if (this.closedAsks.size > ASKS_CLOSED_KEPT) this.closedAsks.delete(this.closedAsks.values().next().value!)
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
private rememberAsk(key: string, sessionID: string, event: EventLike): void {
|
|
1862
|
+
if (this.closedAsks.has(key)) return
|
|
1863
|
+
this.asks.delete(key)
|
|
1864
|
+
this.asks.set(key, { sessionID, event: { type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, seq: ++this.askSeq })
|
|
1865
|
+
if (this.asks.size > ASKS_KEPT) this.asks.delete(this.asks.keys().next().value!)
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Announces every pending permission and form again (after a reconnect: the hub dropped them with the old
|
|
1870
|
+
* connection). A hub without "pendingReplay" would notify a re-sent permission again: it gets the forms only.
|
|
1871
|
+
*/
|
|
1872
|
+
private reannounce(): void {
|
|
1873
|
+
const permissions = this.hubSupports("pendingReplay")
|
|
1874
|
+
for (const ask of [...this.asks.values()]) if (permissions || ask.event.type !== "permission.asked") this.forward(ask.event)
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Asks OpenCode what the sessions wait for (the active ones, and those with something pending here): forms and
|
|
1879
|
+
* permissions this process never saw asked (it started afterwards, or its stream was down) are announced; those
|
|
1880
|
+
* it remembers that OpenCode no longer lists (answered while the stream was down) are closed on the hub.
|
|
1881
|
+
*/
|
|
1882
|
+
private async reconcileAsks(active: readonly string[]): Promise<void> {
|
|
1883
|
+
const pending = this.host.pending
|
|
1884
|
+
if (!pending) return
|
|
1885
|
+
// Sessions with something remembered first: a reply missed while the stream was down is only seen by asking.
|
|
1886
|
+
const ids = [...new Set([...[...this.asks.values()].map((a) => a.sessionID).reverse(), ...active])].slice(0, PENDING_SESSIONS_MAX)
|
|
1887
|
+
if (!ids.length) return
|
|
1888
|
+
const asked = this.askSeq
|
|
1889
|
+
const limit = this.options.pendingTimeoutMs ?? PENDING_TIMEOUT_MS
|
|
1890
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1891
|
+
let lists: ReadonlyArray<PendingAsks>
|
|
1892
|
+
try {
|
|
1893
|
+
const listed = Promise.resolve(pending.call(this.host, ids))
|
|
1894
|
+
listed.catch(() => {}) // a listing that fails after the timeout is not an unhandled rejection
|
|
1895
|
+
const timeout = new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new Error(`no answer within ${limit} ms`)), limit)))
|
|
1896
|
+
lists = await Promise.race([listed, timeout])
|
|
1897
|
+
} catch (cause) {
|
|
1898
|
+
console.error("[hub] could not read pending questions and permissions", errorText(cause))
|
|
1899
|
+
return
|
|
1900
|
+
} finally {
|
|
1901
|
+
clearTimeout(timer)
|
|
1902
|
+
}
|
|
1903
|
+
for (const list of lists) {
|
|
1904
|
+
if (!ids.includes(list.sessionID)) continue
|
|
1905
|
+
const listed = new Set<string>()
|
|
1906
|
+
const found: EventLike[] = []
|
|
1907
|
+
for (const form of Array.isArray(list.forms) ? list.forms : []) {
|
|
1908
|
+
const f = form as Record<string, unknown> | null
|
|
1909
|
+
if (typeof f?.id !== "string" || f.sessionID !== list.sessionID) continue
|
|
1910
|
+
listed.add(`frm ${f.id}`)
|
|
1911
|
+
if (!this.asks.has(`frm ${f.id}`) && !this.closedAsks.has(`frm ${f.id}`)) found.push({ type: "form.created", data: { form: f } })
|
|
1912
|
+
}
|
|
1913
|
+
for (const request of Array.isArray(list.permissions) ? list.permissions : []) {
|
|
1914
|
+
const r = request as Record<string, unknown> | null
|
|
1915
|
+
if (typeof r?.id !== "string" || r.sessionID !== list.sessionID) continue
|
|
1916
|
+
listed.add(`per ${r.id}`)
|
|
1917
|
+
if (!this.asks.has(`per ${r.id}`) && !this.closedAsks.has(`per ${r.id}`)) found.push({ type: "permission.asked", data: r })
|
|
1918
|
+
}
|
|
1919
|
+
const gone: EventLike[] = []
|
|
1920
|
+
for (const [key, ask] of this.asks) {
|
|
1921
|
+
if (ask.sessionID !== list.sessionID || ask.seq > asked || listed.has(key)) continue
|
|
1922
|
+
const id = key.slice(4)
|
|
1923
|
+
// unlisted: the hub drops only this process's hold on it (another process may still hold it).
|
|
1924
|
+
gone.push(
|
|
1925
|
+
key.startsWith("per ")
|
|
1926
|
+
? { type: "permission.replied", data: { sessionID: ask.sessionID, requestID: id, unlisted: true } }
|
|
1927
|
+
: { type: "form.cancelled", data: { sessionID: ask.sessionID, id, unlisted: true } },
|
|
1928
|
+
)
|
|
1929
|
+
}
|
|
1930
|
+
for (const event of [...found, ...gone]) {
|
|
1931
|
+
this.trackAsk(event)
|
|
1932
|
+
this.tasks.observe(event, Date.now())
|
|
1933
|
+
if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
|
|
1934
|
+
this.forward(event)
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1806
1939
|
/**
|
|
1807
1940
|
* Adds who put the item in the inbox (user or hub) to session.inbox.delivered, for hubs that route messages
|
|
1808
1941
|
* (they reset reply-chain hops on a user's turn); when unknown, the event goes out unchanged.
|
|
@@ -1875,6 +2008,7 @@ export class Spoke {
|
|
|
1875
2008
|
const event = { type: "session.seed", data: { sessionID: id, status, jobs, ...(error ? { error } : {}) } }
|
|
1876
2009
|
if (this.connection?.state === "connected" && shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
|
|
1877
2010
|
}
|
|
2011
|
+
await this.reconcileAsks([...active])
|
|
1878
2012
|
void this.fetchInfo()
|
|
1879
2013
|
this.scheduleSnapshot()
|
|
1880
2014
|
} catch (cause) {
|
|
@@ -2027,6 +2161,15 @@ const LISTING_TIMEOUT_MS = 60_000
|
|
|
2027
2161
|
/** Directories whose shells are listed when seeding. */
|
|
2028
2162
|
const SEED_DIRECTORIES = 20
|
|
2029
2163
|
|
|
2164
|
+
/** Pending permissions and forms remembered to announce again (oldest dropped). */
|
|
2165
|
+
const ASKS_KEPT = 200
|
|
2166
|
+
/** Answered or cancelled asks remembered (oldest dropped). */
|
|
2167
|
+
const ASKS_CLOSED_KEPT = 1000
|
|
2168
|
+
/** Sessions whose pending forms and permissions are read from OpenCode in one seed. */
|
|
2169
|
+
const PENDING_SESSIONS_MAX = 50
|
|
2170
|
+
/** Longest the own server may take to list them (SpokeOptions.pendingTimeoutMs). */
|
|
2171
|
+
const PENDING_TIMEOUT_MS = 5000
|
|
2172
|
+
|
|
2030
2173
|
/** Keeps the terminals frame within the protocol's limits. */
|
|
2031
2174
|
function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[] {
|
|
2032
2175
|
return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))
|
package/src/spoke/tracker.ts
CHANGED
|
@@ -94,7 +94,7 @@ export class SessionTracker {
|
|
|
94
94
|
private readonly jobs = new Map<string, { sessionID: string; running: boolean }>()
|
|
95
95
|
/** A session's pending retry (session.retry.scheduled, or a retry status), until its run moves on. */
|
|
96
96
|
private readonly retries = new Map<string, RetryStatus>()
|
|
97
|
-
/** A session's last run failure, until its next run starts. */
|
|
97
|
+
/** A session's last run failure, until its next run starts (or the user dismisses it). */
|
|
98
98
|
private readonly errors = new Map<string, CompactRunError>()
|
|
99
99
|
|
|
100
100
|
/** Applies an event seen in this process. Returns true when the snapshot may have changed. */
|
|
@@ -339,6 +339,11 @@ export class SessionTracker {
|
|
|
339
339
|
return this.errors.get(sessionID)
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/** Forgets the session's last run failure (the user dismissed it). True when there was one. */
|
|
343
|
+
dismissError(sessionID: string): boolean {
|
|
344
|
+
return this.errors.delete(sessionID)
|
|
345
|
+
}
|
|
346
|
+
|
|
342
347
|
/** Sessions whose last run failed (no run started since). */
|
|
343
348
|
failed(): string[] {
|
|
344
349
|
return [...this.errors.keys()]
|
package/src/status.ts
CHANGED
|
@@ -64,6 +64,8 @@ export interface FormFieldSpec {
|
|
|
64
64
|
|
|
65
65
|
export const FORM_MAX_FIELDS = 20
|
|
66
66
|
export const FORM_MAX_OPTIONS = 50
|
|
67
|
+
/** Largest blocker kept and shown (JSON characters): the board, the catalog and the hub's kept copy carry it. */
|
|
68
|
+
export const BLOCKER_MAX_BYTES = 64_000
|
|
67
69
|
const FORM_TEXT_MAX = 2000
|
|
68
70
|
const FORM_LABEL_MAX = 300
|
|
69
71
|
const FIELD_TYPES = new Set<FormFieldSpec["type"]>(["string", "number", "integer", "boolean", "multiselect", "external"])
|
|
@@ -98,6 +100,22 @@ const SOURCE_ID_MAX = 200
|
|
|
98
100
|
export const BLOCKER_MAX_RESOURCES = 10
|
|
99
101
|
const BLOCKER_RESOURCE_MAX = 500
|
|
100
102
|
|
|
103
|
+
/** Longest blocker ID (a longer one is no usable request) and permission summary kept. */
|
|
104
|
+
export const BLOCKER_ID_MAX = 200
|
|
105
|
+
const BLOCKER_SUMMARY_MAX = 2000
|
|
106
|
+
|
|
107
|
+
/** The strings of `list` joined with ", ", at most `max` characters (never joins more than it keeps). */
|
|
108
|
+
function joinBounded(list: unknown, max: number): string {
|
|
109
|
+
if (!Array.isArray(list)) return ""
|
|
110
|
+
let out = ""
|
|
111
|
+
for (const v of list) {
|
|
112
|
+
if (typeof v !== "string") continue
|
|
113
|
+
out = out ? `${out}, ${v}` : v
|
|
114
|
+
if (out.length >= max) return out.slice(0, max)
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
101
119
|
/** Strings of `list`, bounded; `cut` is set when anything was dropped or shortened. */
|
|
102
120
|
function boundedStrings(list: unknown, maxItems: number, maxLength: number): { items: string[]; cut: boolean } {
|
|
103
121
|
if (!Array.isArray(list)) return { items: [], cut: list !== undefined }
|
|
@@ -134,7 +152,7 @@ function permissionBlocker(data: Record<string, any>): Blocker {
|
|
|
134
152
|
return {
|
|
135
153
|
kind: "permission",
|
|
136
154
|
id: data.id,
|
|
137
|
-
summary: [
|
|
155
|
+
summary: [action, joinBounded(data.resources, BLOCKER_SUMMARY_MAX)].filter(Boolean).join(": "),
|
|
138
156
|
...(action ? { action } : {}),
|
|
139
157
|
...(resources.length ? { resources } : {}),
|
|
140
158
|
}
|
|
@@ -169,29 +187,55 @@ export function formFieldSpec(raw: any): FormFieldSpec | undefined {
|
|
|
169
187
|
const d = raw.default
|
|
170
188
|
if (typeof d === "boolean" || num(d) !== undefined) out.default = d
|
|
171
189
|
else if (typeof d === "string") out.default = d.slice(0, FORM_TEXT_MAX)
|
|
172
|
-
else if (Array.isArray(d)) out.default = d.filter((x: unknown) => typeof x === "string").slice(0, FORM_MAX_OPTIONS)
|
|
190
|
+
else if (Array.isArray(d)) out.default = d.filter((x: unknown) => typeof x === "string").slice(0, FORM_MAX_OPTIONS).map((x: string) => x.slice(0, FORM_LABEL_MAX))
|
|
173
191
|
if (Array.isArray(raw.when)) {
|
|
174
192
|
const when = raw.when
|
|
175
193
|
.filter((w: any) => typeof w?.key === "string" && (w.op === "eq" || w.op === "neq") && ["string", "number", "boolean"].includes(typeof w.value))
|
|
176
194
|
.slice(0, 10)
|
|
177
|
-
.map((w: any) => ({ key: w.key, op: w.op, value: w.value }))
|
|
195
|
+
.map((w: any) => ({ key: w.key.slice(0, FORM_LABEL_MAX), op: w.op, value: typeof w.value === "string" ? w.value.slice(0, FORM_LABEL_MAX) : w.value }))
|
|
178
196
|
if (when.length) out.when = when
|
|
179
197
|
}
|
|
180
198
|
return out as unknown as FormFieldSpec
|
|
181
199
|
}
|
|
182
200
|
|
|
183
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* A form.created event's form as a blocker, at most BLOCKER_MAX_BYTES: over it the descriptions go, then the fields
|
|
203
|
+
* (the card then asks to answer in the terminal).
|
|
204
|
+
*/
|
|
184
205
|
function formBlocker(form: any): Blocker {
|
|
185
|
-
const fields =
|
|
186
|
-
|
|
187
|
-
|
|
206
|
+
const fields: FormFieldSpec[] = []
|
|
207
|
+
for (const raw of Array.isArray(form.fields) ? form.fields : []) {
|
|
208
|
+
if (fields.length >= FORM_MAX_FIELDS) break
|
|
209
|
+
const field = formFieldSpec(raw)
|
|
210
|
+
if (field) fields.push(field)
|
|
211
|
+
}
|
|
212
|
+
const head = {
|
|
213
|
+
kind: "form" as const,
|
|
188
214
|
id: form.id,
|
|
189
215
|
summary: typeof form.title === "string" && form.title ? form.title.slice(0, FORM_LABEL_MAX) : "question",
|
|
190
216
|
...(form.metadata?.kind === "question" ? { question: true as const } : {}),
|
|
191
|
-
...(fields.length ? { fields } : {}),
|
|
192
217
|
}
|
|
218
|
+
const full: Blocker = { ...head, ...(fields.length ? { fields } : {}) }
|
|
219
|
+
if (JSON.stringify(full).length <= BLOCKER_MAX_BYTES) return full
|
|
220
|
+
const plain: Blocker = { ...head, fields: fields.map(withoutDescriptions) }
|
|
221
|
+
return JSON.stringify(plain).length <= BLOCKER_MAX_BYTES ? plain : head
|
|
193
222
|
}
|
|
194
223
|
|
|
224
|
+
function withoutDescriptions(field: FormFieldSpec): FormFieldSpec {
|
|
225
|
+
const { description: _, ...rest } = field
|
|
226
|
+
return rest.options ? { ...rest, options: rest.options.map(({ description: _d, ...option }) => option) } : rest
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The blocker a permission.asked or form.created event shows (bounded); undefined for other events. */
|
|
230
|
+
export function askBlocker(event: EventLike): Blocker | undefined {
|
|
231
|
+
const data = (event.data ?? {}) as Record<string, any>
|
|
232
|
+
if (event.type === "permission.asked" && usableID(data.id)) return permissionBlocker(data)
|
|
233
|
+
if (event.type === "form.created" && usableID(data.form?.id)) return formBlocker(data.form)
|
|
234
|
+
return undefined
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const usableID = (id: unknown): id is string => typeof id === "string" && id.length > 0 && id.length <= BLOCKER_ID_MAX
|
|
238
|
+
|
|
195
239
|
/** A model request OpenCode retries: the attempt, why, when the next one runs (epoch ms) and the error's type. */
|
|
196
240
|
export interface RetryInfo {
|
|
197
241
|
readonly attempt?: number
|
|
@@ -271,6 +315,8 @@ interface Entry {
|
|
|
271
315
|
error?: string
|
|
272
316
|
errorType?: string
|
|
273
317
|
errorStatus?: number
|
|
318
|
+
/** The error the user dismissed (fingerprint), so a seed of it (a spoke that cannot forget it) stays hidden. */
|
|
319
|
+
dismissed?: string
|
|
274
320
|
blockers: Blocker[]
|
|
275
321
|
jobs: JobInfo[]
|
|
276
322
|
}
|
|
@@ -313,6 +359,7 @@ export class StatusBoard {
|
|
|
313
359
|
entry.running = true
|
|
314
360
|
entry.retry = undefined
|
|
315
361
|
clearError(entry)
|
|
362
|
+
entry.dismissed = undefined
|
|
316
363
|
})
|
|
317
364
|
case "session.execution.succeeded":
|
|
318
365
|
case "session.execution.interrupted":
|
|
@@ -321,6 +368,7 @@ export class StatusBoard {
|
|
|
321
368
|
return this.update(data.sessionID, (entry) => {
|
|
322
369
|
endRun(entry)
|
|
323
370
|
setError(entry, data.error)
|
|
371
|
+
entry.dismissed = undefined
|
|
324
372
|
})
|
|
325
373
|
// Output resumed: the retry succeeded (OpenCode sends no status for it).
|
|
326
374
|
case "session.text.delta":
|
|
@@ -332,27 +380,39 @@ export class StatusBoard {
|
|
|
332
380
|
entry.retry = undefined
|
|
333
381
|
return true
|
|
334
382
|
}
|
|
335
|
-
case "permission.asked":
|
|
336
|
-
|
|
383
|
+
case "permission.asked": {
|
|
384
|
+
const blocker = askBlocker(event)
|
|
385
|
+
return !!blocker && this.update(data.sessionID, (entry) => {
|
|
337
386
|
if (entry.blockers.some((b) => b.id === data.id)) return
|
|
338
|
-
entry.blockers.push(
|
|
387
|
+
entry.blockers.push(blocker)
|
|
339
388
|
})
|
|
389
|
+
}
|
|
340
390
|
case "permission.replied":
|
|
341
391
|
return this.unblock(data.sessionID, data.requestID)
|
|
342
|
-
case "form.created":
|
|
343
|
-
|
|
392
|
+
case "form.created": {
|
|
393
|
+
const blocker = askBlocker(event)
|
|
394
|
+
return !!blocker && this.update(data.form?.sessionID, (entry) => {
|
|
344
395
|
if (entry.blockers.some((b) => b.id === data.form.id)) return
|
|
345
|
-
entry.blockers.push(
|
|
396
|
+
entry.blockers.push(blocker)
|
|
346
397
|
})
|
|
398
|
+
}
|
|
347
399
|
case "form.replied":
|
|
348
400
|
case "form.cancelled":
|
|
349
401
|
return this.unblock(data.sessionID, data.id)
|
|
350
402
|
case "session.status":
|
|
351
403
|
return this.update(data.sessionID, (entry) => setStatus(entry, data.status))
|
|
404
|
+
// Hub-defined: the user dismissed the session's last error (it shows again on its next failure).
|
|
405
|
+
case "session.error.dismissed": {
|
|
406
|
+
const entry = typeof data.sessionID === "string" ? this.sessions.get(data.sessionID) : undefined
|
|
407
|
+
if (!entry?.error) return false
|
|
408
|
+
entry.dismissed = fingerprint(entry)
|
|
409
|
+
clearError(entry)
|
|
410
|
+
return true
|
|
411
|
+
}
|
|
352
412
|
case "session.seed":
|
|
353
413
|
return this.update(data.sessionID, (entry) => {
|
|
354
414
|
setStatus(entry, data.status)
|
|
355
|
-
if (typeof data.error?.message === "string" && data.error.message) setError(entry, data.error)
|
|
415
|
+
if (typeof data.error?.message === "string" && data.error.message && fingerprint(errorFields(data.error)) !== entry.dismissed) setError(entry, data.error)
|
|
356
416
|
entry.jobs = entry.jobs.filter((job) => job.status !== "running")
|
|
357
417
|
for (const job of Array.isArray(data.jobs) ? data.jobs : []) if (isJob(job)) upsertJob(entry, job)
|
|
358
418
|
})
|
|
@@ -377,6 +437,14 @@ export class StatusBoard {
|
|
|
377
437
|
}
|
|
378
438
|
}
|
|
379
439
|
|
|
440
|
+
/** Puts a pending blocker (askBlocker) back on a session of the tree. True when it was missing. */
|
|
441
|
+
restoreBlocker(sessionID: string, blocker: Blocker): boolean {
|
|
442
|
+
const entry = this.sessions.get(sessionID)
|
|
443
|
+
if (!entry || entry.blockers.some((b) => b.id === blocker.id)) return false
|
|
444
|
+
entry.blockers.push(structuredClone(blocker))
|
|
445
|
+
return true
|
|
446
|
+
}
|
|
447
|
+
|
|
380
448
|
get(id: string): SessionStatus | undefined {
|
|
381
449
|
const entry = this.sessions.get(id)
|
|
382
450
|
return entry && view(entry)
|
|
@@ -477,17 +545,31 @@ function setStatus(entry: Entry, status: any): void {
|
|
|
477
545
|
...(errorStatus !== undefined ? { errorStatus } : {}),
|
|
478
546
|
}
|
|
479
547
|
} else entry.retry = undefined
|
|
480
|
-
if (type === "busy")
|
|
548
|
+
if (type === "busy") {
|
|
549
|
+
clearError(entry)
|
|
550
|
+
entry.dismissed = undefined
|
|
551
|
+
}
|
|
481
552
|
}
|
|
482
553
|
|
|
483
554
|
const ERROR_TYPE_MAX = 100
|
|
484
555
|
const ERROR_MESSAGE_MAX = 1000
|
|
485
556
|
|
|
486
557
|
/** A failed run's error: its message, type and status. */
|
|
558
|
+
function errorFields(error: any): Pick<Entry, "error" | "errorType" | "errorStatus"> {
|
|
559
|
+
return {
|
|
560
|
+
error: typeof error?.message === "string" && error.message ? error.message.slice(0, ERROR_MESSAGE_MAX) : "failed",
|
|
561
|
+
errorType: text(error?.type, ERROR_TYPE_MAX),
|
|
562
|
+
errorStatus: num(error?.status),
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
487
566
|
function setError(entry: Entry, error: any): void {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
567
|
+
Object.assign(entry, errorFields(error))
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** An error as setError keeps it (message, type, status), to recognise a dismissed one when seeded again. */
|
|
571
|
+
function fingerprint(entry: Pick<Entry, "error" | "errorType" | "errorStatus">): string {
|
|
572
|
+
return JSON.stringify([entry.error, entry.errorType ?? null, entry.errorStatus ?? null])
|
|
491
573
|
}
|
|
492
574
|
|
|
493
575
|
function clearError(entry: Entry): void {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-
|
|
1
|
+
import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-Qo2Pd9-O.js";import{L as o,R as s,z as c}from"./index-DifgVuKS.js";import{t as l}from"./ArtifactWorkbench-C37tZZ3e.js";var u=a(`<div class="feature-page av-page">`),d=a(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(a){return(()=>{var f=u();return n(f,e(l,{get id(){return a.id},layout:`page`,get lead(){return[e(t,{get when(){return o()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(i,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>c(a.id)})}})),f})()}export{f as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-
|
|
1
|
+
import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-Qo2Pd9-O.js";import{t as o}from"./ArtifactWorkbench-C37tZZ3e.js";var s=a(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(a){return e(o,{get id(){return a.artifactID},layout:`pane`,onArtifact:e=>a.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(i,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(a.artifactID)}`)),o})()},get trailing(){return a.paneTools}})}export{c as ArtifactPane};
|