@huynle/hub 0.10.0 → 0.11.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 (78) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +20 -4
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +625 -395
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/grants-mcp.ts +65 -0
  8. package/packages/cli/src/mcp-operations.ts +49 -0
  9. package/packages/cli/src/mcp.ts +6 -0
  10. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  11. package/packages/cli/src/teams-mcp.ts +5 -3
  12. package/packages/codex/package.json +1 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/src/index.ts +13 -1
  17. package/packages/protocol/src/teams.ts +4 -0
  18. package/packages/protocol/src/voice-recording.ts +39 -2
  19. package/packages/runtime-openshell/image/entry.sh +3 -1
  20. package/packages/runtime-openshell/image/start.sh +3 -1
  21. package/packages/runtime-openshell/package.json +1 -1
  22. package/packages/runtime-sdk/package.json +1 -1
  23. package/packages/sdk/package.json +1 -1
  24. package/packages/sdk/src/client.ts +13 -0
  25. package/packages/sdk/src/teams.ts +2 -0
  26. package/packages/spoke-sdk/package.json +1 -1
  27. package/src/central/agent/decisions.ts +7 -1
  28. package/src/central/agent/delivery.ts +39 -9
  29. package/src/central/agent/grant-routes.ts +14 -1
  30. package/src/central/agent/grants.ts +23 -0
  31. package/src/central/agent/launch-engine.ts +3 -3
  32. package/src/central/agent/message-limits.ts +3 -0
  33. package/src/central/agent/routes.ts +11 -0
  34. package/src/central/agent/subscriptions.ts +3 -1
  35. package/src/central/config.ts +9 -2
  36. package/src/central/http.ts +135 -24
  37. package/src/central/recording/export.ts +57 -3
  38. package/src/central/recording/index.ts +234 -16
  39. package/src/central/recording/ingest.ts +11 -2
  40. package/src/central/recording/routes.ts +61 -7
  41. package/src/central/recording/schema.ts +66 -2
  42. package/src/central/recording/settings.ts +19 -5
  43. package/src/central/recording/store.ts +19 -0
  44. package/src/central/recording/taps.ts +45 -1
  45. package/src/central/speech/openai.ts +4 -4
  46. package/src/central/speech/openrouter.ts +4 -4
  47. package/src/central/speech/provider.ts +32 -1
  48. package/src/central/speech/service.ts +6 -5
  49. package/src/central/teams/hop-stalls.ts +72 -0
  50. package/src/central/teams/policy.ts +10 -0
  51. package/src/central/teams/routes.ts +2 -0
  52. package/src/central/teams/seats.ts +3 -3
  53. package/src/central/teams/staffing.ts +5 -0
  54. package/src/central/teams/store.ts +5 -2
  55. package/src/central/teams/validation.ts +8 -5
  56. package/src/spoke/spoke.ts +19 -0
  57. package/web/dist/assets/{ArtifactPage-Bz992ZX6.js → ArtifactPage-C5MMqXLN.js} +1 -1
  58. package/web/dist/assets/{ArtifactPane-BJZNixJC.js → ArtifactPane-CXSCmhyJ.js} +1 -1
  59. package/web/dist/assets/{ArtifactWorkbench-Dti3VP07.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
  60. package/web/dist/assets/{Diagram-B4HdUxw0.js → Diagram-BGmyRb-R.js} +1 -1
  61. package/web/dist/assets/{Library-CpzD2HDW.js → Library-CFmMbXEa.js} +2 -2
  62. package/web/dist/assets/{ProfilesDestination-DlQeogea.js → ProfilesDestination-7jpOc2HM.js} +1 -1
  63. package/web/dist/assets/{TeamsPage-4xdWCaI-.js → TeamsPage-C1NGrUR-.js} +1 -1
  64. package/web/dist/assets/{Watchtower-CpQdIRns.js → Watchtower-DnvlJaU0.js} +2 -2
  65. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  66. package/web/dist/assets/index-Da8SKXMw.js +33 -0
  67. package/web/dist/assets/{index-BC0O1gfR.css → index-Dyq-jBeE.css} +1 -1
  68. package/web/dist/assets/live-BmqLASSo.js +5 -0
  69. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  70. package/web/dist/assets/{renderer2d-IaJiloPi.js → renderer2d-B2Nh39UN.js} +1 -1
  71. package/web/dist/assets/{renderer3d-ZiD1uKGW.js → renderer3d-CTYbljHH.js} +1 -1
  72. package/web/dist/assets/{safe-area-C7IAlkgY.js → safe-area-CkhEclGo.js} +1 -1
  73. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  74. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  75. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  76. package/web/dist/index.html +5 -5
  77. package/web/dist/assets/index-BS6lA6fN.js +0 -33
  78. package/web/dist/assets/live-CVUFIdUX.js +0 -5
@@ -7,7 +7,15 @@ export const SETTINGS_COLLECTION = "voice-recording-settings"
7
7
  const KEY = "settings"
8
8
  export const DEFAULT_SETTINGS: RecordingSettings = { enabled: false, revision: 0 }
9
9
 
10
- export type SettingsWrite = { readonly ok: true; readonly settings: RecordingSettings; readonly changed: boolean } | { readonly ok: false; readonly currentRevision: number }
10
+ export type SettingsWrite =
11
+ | { readonly ok: true; readonly settings: RecordingSettings; readonly changed: boolean; readonly developerChanged: boolean }
12
+ | { readonly ok: false; readonly currentRevision: number }
13
+
14
+ /** A PUT's change: either or both (never enabled: false with developer: true, the route refuses it). */
15
+ export interface SettingsChange {
16
+ readonly enabled?: boolean
17
+ readonly developer?: boolean
18
+ }
11
19
 
12
20
  export class RecordingSettingsStore {
13
21
  constructor(
@@ -19,15 +27,21 @@ export class RecordingSettingsStore {
19
27
  return (await this.state.get<RecordingSettings>(userID, SETTINGS_COLLECTION, KEY))?.value ?? DEFAULT_SETTINGS
20
28
  }
21
29
 
22
- /** Sets `enabled` when the stored revision is `expectedRevision`; a stale one answers the current revision. */
23
- async set(userID: string, enabled: boolean, expectedRevision: number): Promise<SettingsWrite> {
30
+ /**
31
+ * Sets `enabled` and/or `developer` when the stored revision is `expectedRevision`; a stale one answers the current
32
+ * revision. Developer mode implies recording: developer on turns recording on, recording off turns developer off;
33
+ * a change of `enabled` alone (an older console) keeps developer as it is.
34
+ */
35
+ async set(userID: string, change: SettingsChange, expectedRevision: number): Promise<SettingsWrite> {
24
36
  for (let attempt = 0; attempt < 8; attempt++) {
25
37
  const stored = await this.state.get<RecordingSettings>(userID, SETTINGS_COLLECTION, KEY)
26
38
  const current = stored?.value ?? DEFAULT_SETTINGS
27
39
  if (current.revision !== expectedRevision) return { ok: false, currentRevision: current.revision }
28
- const next: RecordingSettings = { enabled, revision: current.revision + 1, updatedAt: this.now() }
40
+ const developer = change.developer ?? (change.enabled === false ? false : current.developer === true)
41
+ const enabled = developer || (change.enabled ?? current.enabled)
42
+ const next: RecordingSettings = { enabled, ...(current.developer !== undefined || developer ? { developer } : {}), revision: current.revision + 1, updatedAt: this.now() }
29
43
  const written = await this.state.put(userID, SETTINGS_COLLECTION, KEY, next, { ifVersion: stored?.version ?? 0 })
30
- if (written.ok) return { ok: true, settings: next, changed: current.enabled !== enabled }
44
+ if (written.ok) return { ok: true, settings: next, changed: current.enabled !== enabled, developerChanged: (current.developer === true) !== developer }
31
45
  }
32
46
  const current = await this.get(userID)
33
47
  return { ok: false, currentRevision: current.revision }
@@ -81,6 +81,19 @@ export interface AudioEntry {
81
81
  readonly redacted?: true
82
82
  }
83
83
 
84
+ /** Developer mode: an audio clip (k) or a reply sentence's audio (r), numbered per recording. */
85
+ export interface ExtraEntry {
86
+ readonly hash: string
87
+ readonly bytes: number
88
+ readonly at: number
89
+ /** Replies: the sentence's ref. */
90
+ readonly ref?: { readonly messageID: string; readonly ordinal: number; readonly seq?: number; readonly i: number }
91
+ }
92
+ export const clipKey = (n: number) => `k${String(n).padStart(6, "0")}`
93
+ export const replyKey = (n: number) => `r${String(n).padStart(6, "0")}`
94
+ /** Index keys of event pages (c…, h…); a… are segments, k… clips, r… reply audio. */
95
+ export const isPageKey = (key: string) => key.startsWith("c") || key.startsWith("h")
96
+
84
97
  export const consoleKey = (firstSeq: number) => `c${String(firstSeq).padStart(10, "0")}`
85
98
  export const hubKey = (podTag: string, n: number) => `h${podTag}-${String(n).padStart(8, "0")}`
86
99
  export const audioKey = (n: number) => `a${String(n).padStart(6, "0")}`
@@ -116,6 +129,9 @@ export function summaryOf(r: RecordingRecord): RecordingSummary {
116
129
  events: r.events,
117
130
  audio: r.audio,
118
131
  redactions: r.redactions,
132
+ ...(r.developer ? { developer: true as const } : {}),
133
+ ...(r.clips ? { clips: r.clips } : {}),
134
+ ...(r.replies ? { replies: r.replies } : {}),
119
135
  }
120
136
  }
121
137
 
@@ -274,6 +290,8 @@ export interface StartFacts {
274
290
  readonly anchor: RecordingAnchor
275
291
  readonly audio: boolean
276
292
  readonly at: number
293
+ /** Developer mode was on at start. */
294
+ readonly developer?: boolean
277
295
  }
278
296
 
279
297
  export function newRecord(facts: StartFacts, speech: SpeechFacts): RecordingRecord {
@@ -303,6 +321,7 @@ export function newRecord(facts: StartFacts, speech: SpeechFacts): RecordingReco
303
321
  lastEventAt: facts.at,
304
322
  capNoted: [],
305
323
  skipped: { cap: 0, failed: 0 },
324
+ ...(facts.developer ? { developer: true as const } : {}),
306
325
  ...speech,
307
326
  }
308
327
  }
@@ -10,6 +10,25 @@ export interface TapSink {
10
10
  hub(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>): void
11
11
  /** False when the user surely has no active recording (a fresh, empty cache); true when unsure. */
12
12
  mayRecord(userID: string): boolean
13
+ /** (Developer mode) A turn ended: its timings, for the session's developer recordings (turn.detail). */
14
+ turnDetail?(userID: string, sessionID: string, detail: TurnTimings): void
15
+ }
16
+
17
+ /** A turn's timings (ms from its start), for Developer mode's turn.detail. Tool names only, never their input. */
18
+ export interface TurnTimings {
19
+ readonly outcome: "succeeded" | "failed" | "interrupted"
20
+ readonly ms: number
21
+ readonly firstTokenMs?: number
22
+ readonly tools: ReadonlyArray<{ readonly callID: string; readonly tool?: string; readonly startMs: number; readonly endMs?: number; readonly state: "called" | "success" | "failed" }>
23
+ }
24
+
25
+ /** Tool calls kept per turn in turn.detail. */
26
+ const TURN_TOOLS_MAX = 50
27
+
28
+ interface TurnTrack {
29
+ readonly at: number
30
+ firstTokenMs?: number
31
+ readonly tools: Map<string, { callID: string; tool?: string; startMs: number; endMs?: number; state: "called" | "success" | "failed" }>
13
32
  }
14
33
 
15
34
  const OUTCOMES: Record<string, "succeeded" | "failed" | "interrupted"> = {
@@ -34,8 +53,16 @@ export class RegistryTaps implements RecordingTap {
34
53
  private readonly firstToken = new Set<string>()
35
54
  /** Tool names by call ID (session.tool.input.started carries the name, the later events do not). */
36
55
  private readonly tools = new Map<string, string>()
56
+ /** (Developer mode) The running turn of each root session: its start, first token and tool calls. */
57
+ private readonly turns = new Map<string, TurnTrack>()
58
+ private readonly now: () => number
37
59
 
38
- constructor(private readonly sink: TapSink) {}
60
+ constructor(
61
+ private readonly sink: TapSink,
62
+ now: () => number = Date.now,
63
+ ) {
64
+ this.now = now
65
+ }
39
66
 
40
67
  wants(tenant: string, sessionID: string, type: string): boolean {
41
68
  if (type === "session.text.delta") return this.firstToken.has(sessionID) && this.sink.mayRecord(tenant)
@@ -47,6 +74,7 @@ export class RegistryTaps implements RecordingTap {
47
74
  if (type === "session.execution.started") {
48
75
  this.firstToken.add(sessionID)
49
76
  if (this.firstToken.size > 1000) this.firstToken.delete(this.firstToken.values().next().value as string)
77
+ if (this.sink.turnDetail) remember(this.turns, sessionID, { at: this.now(), tools: new Map() })
50
78
  this.sink.hub(tenant, sessionID, "turn.started", {})
51
79
  return
52
80
  }
@@ -54,10 +82,17 @@ export class RegistryTaps implements RecordingTap {
54
82
  if (outcome) {
55
83
  this.firstToken.delete(sessionID)
56
84
  this.sink.hub(tenant, sessionID, "turn.ended", { outcome })
85
+ const track = this.turns.get(sessionID)
86
+ if (track) {
87
+ this.turns.delete(sessionID)
88
+ this.sink.turnDetail?.(tenant, sessionID, { outcome, ms: this.now() - track.at, ...(track.firstTokenMs !== undefined ? { firstTokenMs: track.firstTokenMs } : {}), tools: [...track.tools.values()] })
89
+ }
57
90
  return
58
91
  }
59
92
  if (type === "session.text.delta") {
60
93
  if (!this.firstToken.delete(sessionID)) return
94
+ const track = this.turns.get(sessionID)
95
+ if (track) track.firstTokenMs = this.now() - track.at
61
96
  const { assistantMessageID: messageID, ordinal } = data
62
97
  this.sink.hub(tenant, sessionID, "first.token", { ...(typeof messageID === "string" ? { messageID } : {}), ...(typeof ordinal === "number" ? { ordinal } : {}) })
63
98
  return
@@ -71,6 +106,15 @@ export class RegistryTaps implements RecordingTap {
71
106
  if (!state || !callID) return
72
107
  const tool = this.tools.get(callID)
73
108
  if (state !== "called") this.tools.delete(callID)
109
+ const track = this.turns.get(sessionID)
110
+ if (track) {
111
+ const ms = this.now() - track.at
112
+ const call = track.tools.get(callID)
113
+ if (call) {
114
+ call.state = state
115
+ if (state !== "called") call.endMs = ms
116
+ } else if (track.tools.size < TURN_TOOLS_MAX) track.tools.set(callID, { callID, ...(tool ? { tool } : {}), startMs: ms, ...(state !== "called" ? { endMs: ms } : {}), state })
117
+ }
74
118
  // The summary only (summarizeToolInput on the spoke): never the input itself, never the output or error.
75
119
  const summary = state === "called" && typeof data.summary === "string" ? data.summary : undefined
76
120
  this.sink.hub(tenant, sessionID, "tool", { callID, ...(tool ? { tool } : {}), ...(summary !== undefined ? { summary } : {}), state })
@@ -1,7 +1,7 @@
1
1
  // OpenAI-compatible speech endpoints (OpenAI, Groq, local whisper/kokoro servers, ...):
2
2
  // STT as multipart POST {baseURL}/audio/transcriptions (file, model, language?), TTS as
3
3
  // POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}.
4
- import { audioBody, audioMime, collect, SpeechError, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider } from "./provider"
4
+ import { audioBody, audioMime, collect, SpeechError, timedTranscription, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider, type TranscribeStats } from "./provider"
5
5
 
6
6
  export interface OpenAICompatibleOptions {
7
7
  /** Base URL including the version path, e.g. https://api.openai.com/v1 (no trailing slash). */
@@ -25,7 +25,7 @@ export class OpenAICompatible implements SpeechProvider {
25
25
  this.verbose = wantsVerbose(options.sttModel)
26
26
  }
27
27
 
28
- async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
28
+ async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, stats?: TranscribeStats): Promise<string> {
29
29
  const headers = await this.headers()
30
30
  const send = (verbose: boolean) => {
31
31
  const form = new FormData()
@@ -36,9 +36,9 @@ export class OpenAICompatible implements SpeechProvider {
36
36
  if (verbose) form.append("response_format", "verbose_json")
37
37
  return this.fetch(`${this.options.baseURL}/audio/transcriptions`, { method: "POST", headers, body: form, signal })
38
38
  }
39
- const response = await withVerboseRetry(send, this.verbose, () => (this.verbose = false))
39
+ const { response, json } = await timedTranscription(() => withVerboseRetry(send, this.verbose, () => (this.verbose = false)), stats)
40
40
  if (!response.ok) throw await failure("transcription", response)
41
- return transcriptText(await response.json())
41
+ return transcriptText(await json())
42
42
  }
43
43
 
44
44
  async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
@@ -1,6 +1,6 @@
1
1
  // OpenRouter speech-to-text (/audio/transcriptions as OpenAI-style multipart: the audio goes up as binary,
2
2
  // not as base64 JSON, a third smaller and no encoding) and text-to-speech (/audio/speech).
3
- import { audioBody, audioMime, collect, SpeechError, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider } from "./provider"
3
+ import { audioBody, audioMime, collect, SpeechError, timedTranscription, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider, type TranscribeStats } from "./provider"
4
4
 
5
5
  export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
6
6
 
@@ -33,7 +33,7 @@ export class OpenRouter implements SpeechProvider {
33
33
  this.verbose = wantsVerbose(options.sttModel)
34
34
  }
35
35
 
36
- async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
36
+ async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, stats?: TranscribeStats): Promise<string> {
37
37
  const { sttModel: model, language } = this.options
38
38
  // OpenRouter documents multipart file/model/language (up to 25 MB; an utterance is at most ~2 MB of WAV), and
39
39
  // response_format verbose_json (segments with Whisper's no_speech_prob) for OpenAI-compatible providers.
@@ -45,9 +45,9 @@ export class OpenRouter implements SpeechProvider {
45
45
  if (verbose) form.append("response_format", "verbose_json")
46
46
  return this.post("/audio/transcriptions", form, signal)
47
47
  }
48
- const response = await withVerboseRetry(send, this.verbose, () => (this.verbose = false))
48
+ const { response, json } = await timedTranscription(() => withVerboseRetry(send, this.verbose, () => (this.verbose = false)), stats)
49
49
  if (!response.ok) throw await failure("transcription", response)
50
- return transcriptText(await response.json())
50
+ return transcriptText(await json())
51
51
  }
52
52
 
53
53
  async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
@@ -1,15 +1,46 @@
1
1
  // Speech provider interface shared by OpenRouter and OpenAI-compatible endpoints, the typed upstream
2
2
  // error (mapped to 502 by the hub), audio format detection, and the speed-retry rule.
3
3
 
4
+ /**
5
+ * (Recording) What a transcription's upstream request looked like, filled in by the provider when asked: the HTTP
6
+ * status, the response's bytes and the time to its headers (ms, from the first request: a verbose retry included).
7
+ */
8
+ export interface TranscribeStats {
9
+ status?: number
10
+ bytes?: number
11
+ headersMs?: number
12
+ }
13
+
4
14
  /** Speech-to-text and text-to-speech against one configured provider. */
5
15
  export interface SpeechProvider {
6
- transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string>
16
+ transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, stats?: TranscribeStats): Promise<string>
7
17
  /** MP3 audio. `speed` is optional; providers that reject it get one retry without it. */
8
18
  speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>>
9
19
  /** The same MP3 as a stream, passed through as the provider sends it (errors are thrown before any byte). */
10
20
  speakStream(text: string, speed?: number, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
11
21
  }
12
22
 
23
+ /**
24
+ * Sends a transcription (`send`, with its retries) noting the status and the time to the headers in `stats`, then
25
+ * reads the JSON body noting its size. A failed status is returned as is (the caller turns it into its error).
26
+ */
27
+ export async function timedTranscription(send: () => Promise<Response>, stats: TranscribeStats | undefined): Promise<{ response: Response; json: () => Promise<unknown> }> {
28
+ const started = performance.now()
29
+ const response = await send()
30
+ if (stats) {
31
+ stats.status = response.status
32
+ stats.headersMs = Math.round(performance.now() - started)
33
+ }
34
+ return {
35
+ response,
36
+ json: async () => {
37
+ const raw = new Uint8Array(await response.arrayBuffer())
38
+ if (stats) stats.bytes = raw.byteLength
39
+ return JSON.parse(new TextDecoder().decode(raw))
40
+ },
41
+ }
42
+ }
43
+
13
44
  /** A successful upstream response's body (an empty stream when it has none). */
14
45
  export function audioBody(response: Response): ReadableStream<Uint8Array> {
15
46
  return response.body ?? new ReadableStream({ start: (controller) => controller.close() })
@@ -3,7 +3,7 @@
3
3
  import { OpenAICompatible } from "./openai"
4
4
  import { OpenRouter } from "./openrouter"
5
5
  import { speechModelList, withCurrent, type SpeechModelInfo, type SpeechModelList } from "./models"
6
- import { SpeechError, upstreamDetail, type SpeechProvider } from "./provider"
6
+ import { SpeechError, upstreamDetail, type SpeechProvider, type TranscribeStats } from "./provider"
7
7
  import {
8
8
  lowerLayers,
9
9
  resolveStt,
@@ -34,7 +34,8 @@ export interface SpeechSettingsStore {
34
34
 
35
35
  /** Speech for one scope: a user (their settings over the default) or, without a user, the server default. */
36
36
  export interface ScopedSpeech {
37
- transcribe(audio: Uint8Array, format: string): Promise<string>
37
+ /** `stats`: the upstream request's status, bytes and time to headers (recording mode). */
38
+ transcribe(audio: Uint8Array, format: string, stats?: TranscribeStats): Promise<string>
38
39
  speak(text: string): Promise<Uint8Array<ArrayBuffer>>
39
40
  /** The audio as the provider streams it; `signal` aborts the upstream request (the phone moved on). */
40
41
  speakStream(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
@@ -94,14 +95,14 @@ export class SpeechService {
94
95
  /** Speech with a user's effective settings (undefined: the server default). */
95
96
  forUser(userID: string | undefined): ScopedSpeech {
96
97
  return {
97
- transcribe: (audio, format) => this.transcribe(audio, format, undefined, userID),
98
+ transcribe: (audio, format, stats) => this.transcribe(audio, format, undefined, userID, stats),
98
99
  speak: (text) => this.speak(text, undefined, userID),
99
100
  speakStream: (text, signal) => this.speakStream(text, signal, userID),
100
101
  }
101
102
  }
102
103
 
103
- transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, userID?: string): Promise<string> {
104
- return this.provider("stt", userID).transcribe(audio, format, signal)
104
+ transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, userID?: string, stats?: TranscribeStats): Promise<string> {
105
+ return this.provider("stt", userID).transcribe(audio, format, signal, stats)
105
106
  }
106
107
 
107
108
  speak(text: string, signal?: AbortSignal, userID?: string): Promise<Uint8Array<ArrayBuffer>> {
@@ -0,0 +1,72 @@
1
+ // One persistent hop-cap stall per team. A reservation prevents simultaneous sends from opening two Decisions.
2
+ import type { StateStore } from "../backend/types"
3
+ import { updateVersioned } from "../agent/versioned"
4
+
5
+ export const TEAM_HOP_STALLS = "team-hop-stalls"
6
+ /** A hub that crashed after reserving, but before opening its Decision, may retry after this long. */
7
+ export const TEAM_HOP_CLAIM_STALE_MS = 5 * 60_000
8
+
9
+ interface Stall {
10
+ readonly sender: string
11
+ readonly recipient: string
12
+ readonly limit: number
13
+ readonly decisionID?: string
14
+ readonly at: number
15
+ }
16
+
17
+ export class TeamHopStalls {
18
+ constructor(private readonly deps: {
19
+ readonly state: StateStore
20
+ readonly seats: (userID: string, teamID: string) => Promise<readonly string[]>
21
+ readonly open: (userID: string, teamID: string, sender: string, recipient: string, limit: number) => Promise<string>
22
+ readonly notice?: (userID: string, teamID: string, decisionID: string) => Promise<void>
23
+ readonly close?: (userID: string, decisionID: string) => Promise<void>
24
+ readonly reset: (userID: string, sessionID: string) => Promise<void>
25
+ readonly now?: () => number
26
+ }) {}
27
+
28
+ async hit(userID: string, teamID: string, sender: string, recipient: string, limit: number): Promise<boolean> {
29
+ const previous = await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID)
30
+ if (previous && previous.value.decisionID === undefined && (this.deps.now ?? Date.now)() - previous.value.at >= TEAM_HOP_CLAIM_STALE_MS) {
31
+ // CAS: never erase an open Decision or a replacement claim made by another caller.
32
+ await this.deps.state.delete(userID, TEAM_HOP_STALLS, teamID, { ifVersion: previous.version })
33
+ }
34
+ const claimed = await this.deps.state.put<Stall>(userID, TEAM_HOP_STALLS, teamID, { sender, recipient, limit, at: (this.deps.now ?? Date.now)() }, { ifVersion: 0 })
35
+ if (!claimed.ok) return false
36
+ try {
37
+ const decisionID = await this.deps.open(userID, teamID, sender, recipient, limit)
38
+ await updateVersioned<Stall>(this.deps.state, userID, TEAM_HOP_STALLS, teamID, (current) => current?.sender === sender && current.recipient === recipient && current.decisionID === undefined ? { ...current, decisionID } : undefined)
39
+ try {
40
+ await this.deps.notice?.(userID, teamID, decisionID)
41
+ } catch (cause) {
42
+ // The Decision was opened and persisted; never erase it because a spoke refused a quiet notice.
43
+ console.error(`[hub] team hop-cap entry notice failed: ${cause instanceof Error ? cause.message : String(cause)}`)
44
+ }
45
+ return true
46
+ } catch (cause) {
47
+ await this.deps.state.delete(userID, TEAM_HOP_STALLS, teamID, { ifVersion: claimed.version })
48
+ throw cause
49
+ }
50
+ }
51
+
52
+ async stalled(userID: string, teamID: string, sessionID: string): Promise<(Stall & { readonly decisionID: string }) | undefined> {
53
+ const stall = (await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID))?.value
54
+ if (!stall?.decisionID || stall.recipient !== sessionID) return undefined
55
+ return stall as Stall & { readonly decisionID: string }
56
+ }
57
+
58
+ async renew(userID: string, teamID: string, decisionID: string): Promise<void> {
59
+ const entry = await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID)
60
+ if (!entry || entry.value.decisionID !== decisionID) throw new Error("No matching team hop-cap Decision")
61
+ for (const sessionID of new Set(await this.deps.seats(userID, teamID))) await this.deps.reset(userID, sessionID)
62
+ await this.deps.state.delete(userID, TEAM_HOP_STALLS, teamID, { ifVersion: entry.version })
63
+ }
64
+
65
+ async clearForHuman(userID: string, sessionID: string): Promise<void> {
66
+ for (const { key, value, version } of await this.deps.state.list<Stall>(userID, TEAM_HOP_STALLS)) {
67
+ if (value.recipient !== sessionID && value.sender !== sessionID) continue
68
+ if (!(await this.deps.state.delete(userID, TEAM_HOP_STALLS, key, { ifVersion: version })).ok) continue
69
+ if (value.decisionID) await this.deps.close?.(userID, value.decisionID)
70
+ }
71
+ }
72
+ }
@@ -109,6 +109,16 @@ const sessionParty = (entry: DirectoryEntry): Party => ({ kind: "session", sessi
109
109
  export class TeamPolicy {
110
110
  constructor(private readonly deps: TeamPolicyDeps) {}
111
111
 
112
+ /** Shared active seat(s) only; cross-team and non-team sends use the global limit. */
113
+ async hopContext(userID: string, from: Party, to: Party, explicitTeamID?: string): Promise<{ teamID: string; limit?: number } | undefined> {
114
+ if (from.kind !== "session" || to.kind !== "session") return undefined
115
+ const ctx = await this.context(userID, [from.sessionID, to.sessionID])
116
+ const shared = seatsIn(ctx, from.sessionID).map((s) => s.teamID).filter((id) => (explicitTeamID === undefined || explicitTeamID === id) && seatsIn(ctx, to.sessionID).some((s) => s.teamID === id) && ctx.teams.get(id)?.state === "active").sort()
117
+ const teamID = shared[0]
118
+ if (!teamID) return undefined
119
+ return { teamID, ...(ctx.teams.get(teamID)?.hopLimit !== undefined ? { limit: ctx.teams.get(teamID)!.hopLimit } : {}) }
120
+ }
121
+
112
122
  /** Starts a send from `from` to `addr` (see the header). */
113
123
  async prepare(userID: string, from: Party, addr: string): Promise<TeamSend> {
114
124
  const resolved = await this.resolve(userID, from, addr)
@@ -249,6 +249,7 @@ export function teamRoutes(deps: TeamRoutesDeps): AgentRoute[] {
249
249
  allow: MANAGE,
250
250
  async handle(context) {
251
251
  const body = await bodyOf(context.request)
252
+ if (body.hopLimit !== undefined && context.actor.kind !== "user") throw new AgentError("forbidden", "Only the user may set a team's hop limit")
252
253
  const key = idempotencyKeyOf(context.request)
253
254
  const { team, created } = await teams.create(context.user.id, body, key === undefined ? {} : { idempotencyKey: key })
254
255
  if (created) await changed(context, "team.create", team.id, teamCreatedEvent(team))
@@ -292,6 +293,7 @@ export function teamRoutes(deps: TeamRoutesDeps): AgentRoute[] {
292
293
  allow: MANAGE,
293
294
  async handle(context) {
294
295
  const { expectedRevision, publish, ...patch } = await bodyOf(context.request)
296
+ if (patch.hopLimit !== undefined && context.actor.kind !== "user") throw new AgentError("forbidden", "Only the user may set a team's hop limit")
295
297
  if (publish !== undefined && typeof publish !== "boolean") throw new AgentError("invalid", "publish must be true or false")
296
298
  const revision = expected(expectedRevision)
297
299
  const { id } = await managed(context)
@@ -47,7 +47,7 @@ export interface TeamSeatRecord extends TeamStaffing {
47
47
  readonly teamVersion: number
48
48
  }
49
49
 
50
- export type DeliveryTeams = ReadonlyMap<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state">>
50
+ export type DeliveryTeams = ReadonlyMap<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state" | "hopLimit">>
51
51
 
52
52
  export interface DeliveryContext {
53
53
  /** Each asked session's current seats (empty when it holds none). */
@@ -199,11 +199,11 @@ export class TeamSeatStore {
199
199
  /** Seats of several sessions and the teams they are in: teamDeliveryVerdict's inputs (HWA-34). */
200
200
  async deliveryContext(userID: string, sessionIDs: readonly string[]): Promise<DeliveryContext> {
201
201
  const seats = await this.seatsBySession(userID, sessionIDs)
202
- const teams = new Map<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state">>()
202
+ const teams = new Map<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state" | "hopLimit">>()
203
203
  for (const teamID of new Set([...seats.values()].flat().map((s) => s.teamID))) {
204
204
  try {
205
205
  const team = await this.teams.get(userID, teamID)
206
- if (team) teams.set(teamID, { mode: team.mode, edges: team.edges, roles: team.roles, state: team.state })
206
+ if (team) teams.set(teamID, { mode: team.mode, edges: team.edges, roles: team.roles, state: team.state, ...(team.hopLimit !== undefined ? { hopLimit: team.hopLimit } : {}) })
207
207
  } catch {
208
208
  // A team this build cannot read takes no part in delivery (its seats are then ignored by the verdict).
209
209
  }
@@ -75,6 +75,7 @@ import { readSeatsDoc, type SeatsDoc, TEAM_SEATS } from "./collections"
75
75
  import { teamStaffedEvent, teamUnstaffedEvent } from "./events"
76
76
  import type { TeamSeatRecord, TeamSeatStore } from "./seats"
77
77
  import type { Team, TeamStore } from "./store"
78
+ import type { TeamHopStalls } from "./hop-stalls"
78
79
  import { TEAM_STORE_LIMITS, type TeamRoleSpec } from "./validation"
79
80
 
80
81
  /** One aggregate per team (key: team ID): workspace link, managed members, launch requests, Idempotency-Keys. */
@@ -144,6 +145,8 @@ export interface SeatProfileStatus {
144
145
  export interface RosterSeat {
145
146
  readonly staffing: TeamSeatRecord
146
147
  readonly presence: SessionPresence
148
+ /** An agent-only message to this seat hit the team's hop limit; only the user may renew. */
149
+ readonly stalled?: "hop cap"
147
150
  readonly memberID?: string
148
151
  readonly supervisorMemberID?: string
149
152
  readonly profile?: SeatProfileStatus
@@ -208,6 +211,7 @@ export interface TeamStaffingDeps {
208
211
  readonly members: MembershipStore
209
212
  readonly supervision: SupervisionStore
210
213
  readonly presence: SessionPresenceLookup
214
+ readonly hopStalls?: Pick<TeamHopStalls, "stalled">
211
215
  /** The session's project directory now (grant scope); undefined when no spoke reports it. */
212
216
  readonly sessionDirectory: (userID: string, sessionID: string) => string | undefined
213
217
  readonly launches: Pick<LaunchEngine, "accept" | "view">
@@ -454,6 +458,7 @@ export class TeamStaffingService {
454
458
  seats.push({
455
459
  staffing,
456
460
  presence: presence.get(staffing.sessionID)!,
461
+ ...(await this.deps.hopStalls?.stalled(userID, teamID, staffing.sessionID) ? { stalled: "hop cap" as const } : {}),
457
462
  ...(memberID !== undefined ? { memberID } : {}),
458
463
  ...(supervisorMemberID !== undefined ? { supervisorMemberID } : {}),
459
464
  ...(profile ? { profile } : {}),
@@ -132,6 +132,7 @@ export type PinnedVersions = (userID: string, teamID: string) => Promise<Readonl
132
132
 
133
133
  export interface TeamStoreDeps {
134
134
  readonly state: StateStore
135
+ readonly globalHopLimit?: number
135
136
  readonly now?: Clock
136
137
  readonly newID?: (kind: IdKind) => string
137
138
  readonly limits?: Partial<TeamStoreLimits>
@@ -256,12 +257,14 @@ export class TeamStore {
256
257
  private readonly now: Clock
257
258
  private readonly newID: (kind: IdKind) => string
258
259
  private readonly pinned?: PinnedVersions
260
+ private readonly globalHopLimit: number
259
261
 
260
262
  constructor(deps: TeamStoreDeps) {
261
263
  this.state = deps.state
262
264
  this.now = deps.now ?? Date.now
263
265
  this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
264
266
  this.pinned = deps.pinnedVersions
267
+ this.globalHopLimit = deps.globalHopLimit ?? 8
265
268
  this.limits = {
266
269
  teamsPerUser: deps.limits?.teamsPerUser ?? TEAM_LIMITS.teamsPerUser,
267
270
  versionsRetained: deps.limits?.versionsRetained ?? TEAM_STORE_LIMITS.versionsRetained,
@@ -277,7 +280,7 @@ export class TeamStore {
277
280
  * another body is a conflict.
278
281
  */
279
282
  async create(userID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ team: Team; created: boolean }> {
280
- const spec = teamSpecOf(input)
283
+ const spec = teamSpecOf(input, this.globalHopLimit)
281
284
  const key = options.idempotencyKey
282
285
  if (key === undefined) return { team: await this.insert(userID, this.newID("team"), spec), created: true }
283
286
  if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
@@ -365,7 +368,7 @@ export class TeamStore {
365
368
  const expected = checkExpected(expectedRevision)
366
369
  return this.mutate(userID, id, expected, (record) => {
367
370
  this.active(record)
368
- const spec = teamPatchOf(record.draft?.spec ?? record.spec, patch)
371
+ const spec = teamPatchOf(record.draft?.spec ?? record.spec, patch, this.globalHopLimit)
369
372
  return { ...record, draft: { baseVersion: record.version, spec, digest: specDigest(spec), savedAt: this.now() } }
370
373
  })
371
374
  }
@@ -63,6 +63,7 @@ export interface TeamSpec {
63
63
  readonly title: string
64
64
  readonly description?: string
65
65
  readonly mode: TeamMode
66
+ readonly hopLimit?: number
66
67
  readonly roles: readonly TeamRoleSpec[]
67
68
  readonly entryRole: string
68
69
  readonly edges: readonly TeamEdge[]
@@ -82,13 +83,13 @@ export interface TeamDiff {
82
83
  readonly projects: { readonly added: readonly string[]; readonly removed: readonly string[] }
83
84
  }
84
85
 
85
- export const SPEC_FIELDS: ReadonlySet<string> = new Set(["name", "title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"])
86
+ export const SPEC_FIELDS: ReadonlySet<string> = new Set(["name", "title", "description", "mode", "hopLimit", "roles", "entryRole", "edges", "tags", "metadata", "projects"])
86
87
  const ROLE_FIELDS = new Set(["name", "label", "description", "externalContact", "responsibilities", "profile", "prefs", "minMembers", "maxMembers"])
87
88
  const EDGE_FIELDS = new Set(["from", "to", "kinds", "direction"])
88
89
  const PROFILE_FIELDS = new Set(["profileID", "version"])
89
90
  const PREFS_FIELDS = new Set(["harness", "model", "machineID"])
90
91
  const REQUIRED = new Set(["title", "mode", "roles", "entryRole"])
91
- const SCALARS = ["title", "description", "mode", "entryRole"] as const
92
+ const SCALARS = ["title", "description", "mode", "entryRole", "hopLimit"] as const
92
93
 
93
94
  const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
94
95
  const fail = (message: string): never => {
@@ -157,13 +158,14 @@ function normalizedRole(r: Record<string, unknown>): TeamRoleSpec {
157
158
  }
158
159
 
159
160
  /** A validated, normalized team spec; throws AgentError invalid. */
160
- export function teamSpecOf(input: unknown): TeamSpec {
161
+ export function teamSpecOf(input: unknown, globalHopLimit = 8): TeamSpec {
161
162
  if (!isObj(input)) return fail("body must be an object")
162
163
  onlyFields(input, SPEC_FIELDS, "")
163
164
  if (Array.isArray(input.roles)) for (const role of input.roles) if (isObj(role)) onlyFields(role, ROLE_FIELDS, `role ${printable(String(role.name))}: `)
164
165
  if (Array.isArray(input.edges)) for (const edge of input.edges) if (isObj(edge)) onlyFields(edge, EDGE_FIELDS, "edge: ")
165
166
  const problem = teamInputProblem(input)
166
167
  if (problem) fail(problem)
168
+ if (input.hopLimit !== undefined && (input.hopLimit as number) > globalHopLimit) fail(`hopLimit must not exceed the global agents.hopLimit (${globalHopLimit})`)
167
169
  const roles = input.roles as Record<string, unknown>[]
168
170
  for (const role of roles) {
169
171
  const p = roleProblem(role)
@@ -193,6 +195,7 @@ export function teamSpecOf(input: unknown): TeamSpec {
193
195
  title: input.title as string,
194
196
  ...(input.description !== undefined ? { description: input.description as string } : {}),
195
197
  mode: input.mode as TeamMode,
198
+ ...(input.hopLimit !== undefined ? { hopLimit: input.hopLimit as number } : {}),
196
199
  roles: roles.map(normalizedRole),
197
200
  entryRole: input.entryRole as string,
198
201
  edges,
@@ -206,7 +209,7 @@ export function teamSpecOf(input: unknown): TeamSpec {
206
209
  * The spec after a patch: fields replace the base's (roles and edges wholesale), null clears an optional one, the
207
210
  * name never changes (it is the `#team` address), and the result is validated as a whole.
208
211
  */
209
- export function teamPatchOf(base: TeamSpec, patch: unknown): TeamSpec {
212
+ export function teamPatchOf(base: TeamSpec, patch: unknown, globalHopLimit = 8): TeamSpec {
210
213
  if (!isObj(patch)) return fail("body must be an object")
211
214
  onlyFields(patch, SPEC_FIELDS, "")
212
215
  const keys = Object.keys(patch)
@@ -221,7 +224,7 @@ export function teamPatchOf(base: TeamSpec, patch: unknown): TeamSpec {
221
224
  else delete next[key]
222
225
  } else if (value !== undefined) next[key] = value
223
226
  }
224
- return teamSpecOf(next)
227
+ return teamSpecOf(next, globalHopLimit)
225
228
  }
226
229
 
227
230
  /** sha256 of the spec's canonical JSON (key order never matters). */
@@ -28,6 +28,7 @@ import {
28
28
  type SessionTokensRequest,
29
29
  type ActTokensRequest,
30
30
  type CreateRequest,
31
+ type ModelSelection,
31
32
  type SessionModelPrefs,
32
33
  type SpokeRequest,
33
34
  type TerminalInfo,
@@ -1419,6 +1420,8 @@ export class Spoke {
1419
1420
  else this.modelPrefs.delete(req.sessionID)
1420
1421
  return this.modelPrefsResult(req.sessionID)
1421
1422
  }
1423
+ case "setModel":
1424
+ return this.setSessionModel(req.sessionID, req.model)
1422
1425
  case "timeline": {
1423
1426
  // A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly. A
1424
1427
  // staged revert hides its message and everything after it (OpenCode keeps them until it is committed).
@@ -1940,6 +1943,22 @@ export class Spoke {
1940
1943
  await this.host.switchModel(sessionID, model)
1941
1944
  }
1942
1945
 
1946
+ /**
1947
+ * (feature "setModel") The session's own model, as the console picked it: OpenCode's session.switchModel stores it
1948
+ * on the session, so every later turn runs on it whoever prompts (the console, the TUI, a voice relay). Only a
1949
+ * model (and variant) of this OpenCode's catalog; no variant: the model's default.
1950
+ */
1951
+ private async setSessionModel(sessionID: string, model: ModelSelection): Promise<ResultFor["setModel"]> {
1952
+ const catalog = (await this.host.listModels?.()) ?? []
1953
+ const known = catalog.find((value: any) => value?.providerID === model.providerID && (typeof value.id === "string" && value.id ? value.id : value.modelID) === model.modelID) as any
1954
+ const variants: string[] = Array.isArray(known?.variants) ? known.variants.flatMap((v: any) => (typeof v?.id === "string" ? [v.id] : [])) : []
1955
+ const variant = model.variant && model.variant !== "default" ? model.variant : undefined
1956
+ if (!known || (variant && !variants.includes(variant))) throw new Error(`Unknown model ${model.providerID}/${model.modelID}${variant ? `#${variant}` : ""} for this session`)
1957
+ const ref: ModelRef = { providerID: model.providerID, id: model.modelID, ...(variant ? { variant } : {}) }
1958
+ await this.switchModel(sessionID, ref)
1959
+ return { model: sessionModel({ chosen: ref, models: catalog })! }
1960
+ }
1961
+
1943
1962
  /** Enables or disables voice for a session this process reported (mode "session" only). */
1944
1963
  private expose(sessionID: string, on: boolean): ResultFor["expose"] {
1945
1964
  if (!this.tracker.has(sessionID)) throw new Error("Unknown session")
@@ -1 +1 @@
1
- import{B as e,I as t,S as n,i as r,k as i,r as a}from"./ui-CFB4wQfF.js";import{o}from"./artifacts-But87Jfo.js";import{it as s,rt as c}from"./index-BS6lA6fN.js";import{t as l}from"./ArtifactWorkbench-Dti3VP07.js";var u=i(`<div class="feature-page av-page">`),d=i(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(i){return(()=>{var f=u();return n(f,e(l,{get id(){return i.id},layout:`page`,get lead(){return[e(t,{get when(){return c()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(a,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>o(i.id)})}})),f})()}export{f as default};
1
+ import{B as e,I as t,S as n,i as r,k as i,r as a}from"./ui-CFB4wQfF.js";import{o}from"./artifacts-DlQEYmDl.js";import{it as s,rt as c}from"./index-Da8SKXMw.js";import{t as l}from"./ArtifactWorkbench-S0RxNGWy.js";var u=i(`<div class="feature-page av-page">`),d=i(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(i){return(()=>{var f=u();return n(f,e(l,{get id(){return i.id},layout:`page`,get lead(){return[e(t,{get when(){return c()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(a,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>o(i.id)})}})),f})()}export{f as default};