@huynle/hub 0.9.2 → 0.10.1

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 (141) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +154 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +534 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -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/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +81 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +547 -58
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +227 -237
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-CuRIffka.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-bX8B6NLo.js +33 -0
  123. package/web/dist/assets/index-rW6LKQj1.css +1 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -0,0 +1,67 @@
1
+ // Context card storage: StateStore collection `context-cards`, tenant = hub user, key = session ID, one record per
2
+ // session holding the newest card of each provider ({cards: {[provider]: ContextCard}}). One key per session keeps
3
+ // a session's cards one read (the Details rail, the agent API) and its removal one delete (session deleted).
4
+ // Writes are version-checked (updateVersioned), so two providers finishing at once both land.
5
+ import type { ContextCard } from "../../protocol"
6
+ import type { StateStore } from "../backend/types"
7
+ import { updateVersioned } from "../agent/versioned"
8
+
9
+ export const CONTEXT_CARDS = "context-cards"
10
+
11
+ interface StoredCards {
12
+ readonly cards: Readonly<Record<string, ContextCard>>
13
+ }
14
+
15
+ const cardsOf = (record: StoredCards | undefined): ContextCard[] => Object.values(record?.cards ?? {})
16
+ const newest = (record: StoredCards | undefined) => Math.max(0, ...cardsOf(record).map((c) => c.generatedAt))
17
+
18
+ export class ContextCardStore {
19
+ constructor(private readonly deps: { readonly state: StateStore }) {}
20
+
21
+ /** Stores (replaces) the card of its provider. */
22
+ async put(userID: string, card: ContextCard): Promise<void> {
23
+ await updateVersioned<StoredCards>(this.deps.state, userID, CONTEXT_CARDS, card.sessionID, (current) => ({ cards: { ...current?.cards, [card.provider]: card } }))
24
+ }
25
+
26
+ /** A session's cards, primary first, then by provider id. */
27
+ async list(userID: string, sessionID: string): Promise<ContextCard[]> {
28
+ const record = (await this.deps.state.get<StoredCards>(userID, CONTEXT_CARDS, sessionID))?.value
29
+ return cardsOf(record).sort((a, b) => Number(b.primary === true) - Number(a.primary === true) || a.provider.localeCompare(b.provider))
30
+ }
31
+
32
+ async get(userID: string, sessionID: string, provider: string): Promise<ContextCard | undefined> {
33
+ return (await this.deps.state.get<StoredCards>(userID, CONTEXT_CARDS, sessionID))?.value?.cards[provider]
34
+ }
35
+
36
+ /** The primary card of each of these sessions that has one (one read each, concurrently). */
37
+ async primaries(userID: string, sessionIDs: Iterable<string>): Promise<Map<string, ContextCard>> {
38
+ const ids = [...new Set(sessionIDs)]
39
+ const records = await Promise.all(ids.map((id) => this.deps.state.get<StoredCards>(userID, CONTEXT_CARDS, id)))
40
+ const out = new Map<string, ContextCard>()
41
+ records.forEach((record, i) => {
42
+ const primary = cardsOf(record?.value).find((c) => c.primary)
43
+ if (primary) out.set(ids[i]!, primary)
44
+ })
45
+ return out
46
+ }
47
+
48
+ /** Drops a session's cards. */
49
+ async forget(userID: string, sessionID: string): Promise<void> {
50
+ await this.deps.state.delete(userID, CONTEXT_CARDS, sessionID)
51
+ }
52
+
53
+ /** Drops every card of a user (they turned cards off). */
54
+ async clear(userID: string): Promise<void> {
55
+ for (const { key } of await this.deps.state.list<StoredCards>(userID, CONTEXT_CARDS)) await this.deps.state.delete(userID, CONTEXT_CARDS, key)
56
+ }
57
+
58
+ /** Drops the records whose newest card is older than `before`; answers how many. */
59
+ async prune(userID: string, before: number): Promise<number> {
60
+ let dropped = 0
61
+ for (const { key, value, version } of await this.deps.state.list<StoredCards>(userID, CONTEXT_CARDS)) {
62
+ if (newest(value) >= before) continue
63
+ if ((await this.deps.state.delete(userID, CONTEXT_CARDS, key, { ifVersion: version })).ok) dropped++
64
+ }
65
+ return dropped
66
+ }
67
+ }
@@ -0,0 +1,88 @@
1
+ // Context card provider contract (docs/context-cards.md "Writing a provider"). A provider declares what makes it
2
+ // run (triggers), how often at most (minIntervalMs), what it reads (needs) and generate(), which answers a draft
3
+ // card (or undefined: nothing to show / skip). The engine (./engine.ts) gathers the inputs, hashes them, checks
4
+ // the gates (kill switch, setting, human activity, throttle, budget), calls generate() and bounds the result.
5
+ import type { ContextCardField, ContextCardTrigger } from "../../protocol"
6
+ import type { ChatRequest, ChatResult } from "../llm/chat"
7
+
8
+ /** Inputs a provider may need; the engine fetches only these (a spoke round trip each for transcript and changes). */
9
+ export type InputNeed = "transcript" | "status" | "intent" | "changes"
10
+
11
+ /** One transcript row, already sanitised and bounded by the gatherer (./inputs.ts). */
12
+ export interface TranscriptRow {
13
+ readonly kind: "user" | "assistant" | "tool" | "error" | "message"
14
+ readonly text?: string
15
+ /** Tool rows: the tool's name. */
16
+ readonly name?: string
17
+ /** Tool rows: running | done | failed. */
18
+ readonly state?: string
19
+ }
20
+
21
+ export interface ChangesInput {
22
+ readonly branch?: string
23
+ readonly files: readonly { readonly file: string; readonly additions: number; readonly deletions: number }[]
24
+ readonly totalFiles: number
25
+ readonly additions: number
26
+ readonly deletions: number
27
+ }
28
+
29
+ /** What the engine gathered for a run (only the needs a provider declared are present). */
30
+ export interface CardInputs {
31
+ readonly sessionID: string
32
+ /** status: the live title (may be a placeholder). */
33
+ readonly title?: string
34
+ /** status: idle | working | blocked | error. */
35
+ readonly state?: string
36
+ /** status: open blockers' one-line summaries. */
37
+ readonly blockers?: readonly string[]
38
+ /** intent: the session's effective intent text. */
39
+ readonly intent?: string
40
+ /** transcript: the newest rows, oldest first. */
41
+ readonly transcript?: readonly TranscriptRow[]
42
+ readonly changes?: ChangesInput
43
+ }
44
+
45
+ /** What generate() answers; the engine stamps the rest and bounds every field. */
46
+ export interface CardDraft {
47
+ /** Default: the provider's label. */
48
+ readonly title?: string
49
+ readonly headline: string
50
+ readonly body?: string
51
+ readonly fields?: readonly ContextCardField[]
52
+ /** The model that wrote it. */
53
+ readonly model?: string
54
+ }
55
+
56
+ /** The model as a provider sees it: usage is counted against the user's daily budget by the engine. */
57
+ export interface CardChat {
58
+ readonly model: string
59
+ complete(request: Omit<ChatRequest, "model">): Promise<ChatResult>
60
+ }
61
+
62
+ export interface GenerateContext {
63
+ readonly userID: string
64
+ readonly sessionID: string
65
+ readonly trigger: ContextCardTrigger
66
+ readonly inputs: CardInputs
67
+ /** Present for providers with usesModel (the engine skips them when no model is configured). */
68
+ readonly chat?: CardChat
69
+ readonly now: number
70
+ }
71
+
72
+ export interface ContextCardProvider {
73
+ /** Lowercase id, 2–32 characters (`pickup`). */
74
+ readonly id: string
75
+ readonly label: string
76
+ /** Bump when the output changes: cards of an older version are regenerated even with unchanged inputs. */
77
+ readonly version: number
78
+ /** The primary card (at most one provider): its headline is the session list's second line. */
79
+ readonly primary?: boolean
80
+ /** It calls the model (needs one configured; counts against the daily budget). */
81
+ readonly usesModel?: boolean
82
+ /** Automatic triggers it runs on ("manual" refreshes always may). */
83
+ readonly triggers: readonly ContextCardTrigger[]
84
+ /** At most one automatic run per session this often (later triggers run once the interval is over). */
85
+ readonly minIntervalMs: number
86
+ readonly needs: readonly InputNeed[]
87
+ generate(context: GenerateContext): Promise<CardDraft | undefined>
88
+ }
@@ -139,13 +139,21 @@ export function isDeniedAddress(address: string, extra: readonly Cidr[] = []): b
139
139
  return false
140
140
  }
141
141
 
142
+ /** Whether `address` (a plain IPv4/IPv6 address, IPv4-mapped IPv6 read as IPv4) lies in one of `blocks`. */
143
+ export function inCidrs(address: string, blocks: readonly Cidr[]): boolean {
144
+ const raw = address.trim().replace(/^\[(.*)\]$/, "$1")
145
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(raw)
146
+ const ip = parseIP(mapped ? mapped[1]! : raw)
147
+ return ip !== undefined && blocks.some((block) => contains(block, ip))
148
+ }
149
+
142
150
  /** HUB_FETCH_DENY_CIDRS: a comma list of CIDRs or bare addresses (v4 or v6). Throws on any invalid entry. */
143
- export function parseDenyCidrs(text: string | undefined): Cidr[] {
151
+ export function parseDenyCidrs(text: string | undefined, setting = "HUB_FETCH_DENY_CIDRS"): Cidr[] {
144
152
  const out: Cidr[] = []
145
153
  for (const raw of (text ?? "").split(",")) {
146
154
  const entry = raw.trim()
147
155
  if (!entry) continue
148
- const invalid = () => new Error(`HUB_FETCH_DENY_CIDRS: ${JSON.stringify(entry)} is not an IPv4 or IPv6 CIDR (e.g. 10.42.0.0/16, fd00::/8)`)
156
+ const invalid = () => new Error(`${setting}: ${JSON.stringify(entry)} is not an IPv4 or IPv6 CIDR (e.g. 10.42.0.0/16, fd00::/8)`)
149
157
  const match = /^([^/\s]+)(?:\/(\d{1,3}))?$/.exec(entry)
150
158
  if (!match) throw invalid()
151
159
  const ip = parseIP(match[1]!)
@@ -0,0 +1,423 @@
1
+ // (voice front P2, contract "P2: Concierge mode", decision el64mka7) The hub's side of a Concierge front's
2
+ // relay_to_session, as the Registry uses it:
3
+ // - Utterance bindings. Each utterance the hub steers into a front is recorded (inbox ID, device, time), and the
4
+ // front's latest delivered input (session.inbox.delivered from its linking process: the user's utterances and the
5
+ // spoke's wakes alike). A relay is allowed only while that latest input is a recorded utterance, at most
6
+ // RELAY_WINDOW_MS old, with fewer than RELAYS_PER_UTTERANCE relays made for it. A wake (or anything else) delivered
7
+ // after the utterance ends its relays: a front never relays on its own initiative.
8
+ // - Relayed sessions. A session the front relayed to is tracked for RELAY_TRACK_MS after the latest relay: its root's
9
+ // turn ends become updates for the front (frontUpdate), with only text written after the relay (no stale news). While
10
+ // a steered relay is not delivered yet, a turn end is held and folded into the next one (a held "finished" report is
11
+ // superseded; failures and interruptions kept), capped at RELAY_HOLD_MS: one report per job, as the session-mode
12
+ // front's fix C.
13
+ import type { Timers } from "./backend/types"
14
+ import { redactCredentials } from "../../packages/protocol/src/credentials.ts"
15
+
16
+ export const RELAYS_PER_UTTERANCE = 2
17
+ export const RELAY_WINDOW_MS = 120_000
18
+ export const RELAY_TRACK_MS = 30 * 60_000
19
+ export const RELAY_HOLD_MS = 60_000
20
+ /** The audit reason of a relayed prompt (the user's own, through the Concierge front). */
21
+ export const RELAY_REASON = "Relayed by voice from you (Concierge front)"
22
+ /** (guard tuning) The audit reason of a relay the front confirmed after the hub's soft question refusal. */
23
+ export const RELAY_REASON_CONFIRMED = "Relayed by voice from you (Concierge front), confirmed after a question check"
24
+ /** A relayed session's final text in an update, at most. */
25
+ const TEXT_MAX = 1500
26
+ /** Utterances remembered per front. */
27
+ const UTTERANCES_KEPT = 10
28
+ const FRONTS_KEPT = 1000
29
+ const TARGETS_KEPT = 1000
30
+
31
+ export class RelayRefused extends Error {
32
+ /** The device of the utterance the refused relay answered, when known (for the audit). */
33
+ constructor(
34
+ message: string,
35
+ readonly deviceID?: string,
36
+ ) {
37
+ super(message)
38
+ }
39
+ }
40
+
41
+ /** (hardening) The plain marker every relayed message starts with. */
42
+ export const RELAY_MARKER = "(by voice, via the Concierge, for you)"
43
+
44
+ /** (hands-on 2026-11-09) What the front reads when the hub refuses a status question as a relay. */
45
+ export const STATUS_QUESTION_REFUSAL =
46
+ "That's a question about the session, not a request for it: answer the user from the board in a sentence or two, or pass it to the Concierge with voice_main_send. Nothing was sent"
47
+ /**
48
+ * (guard tuning) The refusal of a question that also has a work verb ("did hub run the tests?" may be "have hub run
49
+ * the tests"): the front may retry once with confirm.
50
+ */
51
+ export const STATUS_QUESTION_SOFT_REFUSAL = `${STATUS_QUESTION_REFUSAL}. If the user did ask for work to be done there, you may retry ONCE with confirm: true, after saying aloud exactly what you will send, for example "Sending 'update the changelog' to docs."`
52
+
53
+ /** Verbs that ask for work (imperatives, and the verb after a modal or "should"); "check" is special (check on = status). */
54
+ const WORK_VERBS: ReadonlySet<string> = new Set(
55
+ ("tell ask have make get let send run rerun fix create add update review commit merge push pull squash rebase release deploy " +
56
+ "restart start stop retry rename delete remove refactor implement install bump prepare draft edit move write lint summarize " +
57
+ "summarise investigate debug keep continue open close undo revert build test scale clean sync generate document look check " +
58
+ "finish change reply answer post publish upload download copy split format rewrite optimize optimise migrate pin unpin tag").split(" "),
59
+ )
60
+ /** After "can/could you" (or "we"): verbs that ask the assistant for information, not work in a session. */
61
+ const INFO_VERBS: ReadonlySet<string> = new Set(["check", "see", "find", "tell", "let", "show", "give", "remind", "look", "hear", "say", "know", "confirm"])
62
+ /** Words that open a question about a session. */
63
+ const QUESTION_START = /^(?:did|has|is|are|was|were|does|do|what|where|when|why|who|whose|which|how|any)\b/
64
+ const LEADING_FILLER = /^(?:ok(?:ay)?|so|hey|hi|uh|um|er|erm|please|and|alright|right|well|also|now|then|oh|yeah|yes|concierge)\b[\s,.!]*/
65
+ /** Status phrases anywhere (a question about a session, if no request was found first). */
66
+ const STATUS_PHRASE = /\b(?:check(?:ing)?\s+(?:in\s+)?(?:up\s+)?on|how(?:'s|\s+is|\s+are)|status|any\s+(?:news|update)|news\s+from|hear(?:d)?\s+from|what\s+happened|where\s+is\b.*\bat\b|is\b.*\b(?:done|finished|waiting|stuck|ready)|working\s+on|doing|up\s+to)\b/
67
+ /** A delegation inside a question ("how about you tell hub to run the tests"). */
68
+ const DELEGATION = /\b(?:tell|ask)\s+(?!me\b|us\b|you\b)[^?]*?\bto\b|\b(?:have|get|make)\s+(?!you\b|we\b|me\b|us\b|i\b|it\b|the\b|any\b)\w+(?:\s+\w+){0,2}\s+(?:to\s+)?\w+/
69
+
70
+ const normalise = (words: string) =>
71
+ words
72
+ .normalize("NFKC")
73
+ .replace(/\p{Cf}/gu, "")
74
+ .replace(/[\u2018\u2019]/g, "'")
75
+ .toLowerCase()
76
+ .replace(/\s+/g, " ")
77
+ .trim()
78
+
79
+ const stripFillers = (text: string) => {
80
+ let t = text
81
+ for (let i = 0; i < 6; i++) {
82
+ const next = t.replace(LEADING_FILLER, "")
83
+ if (next === t) break
84
+ t = next
85
+ }
86
+ return t
87
+ }
88
+
89
+ const firstWord = (text: string) => /^[a-z']+/.exec(text)?.[0] ?? ""
90
+
91
+ /** A request at the start of `text`: an imperative, a modal request, "<subject> should …", "keep …". */
92
+ function requestAt(text: string): boolean {
93
+ const t = stripFillers(text)
94
+ const w = firstWord(t)
95
+ // "check on X", "check in on X": status; any other "check …" (check in X whether…, check the logs) is work.
96
+ if (w === "check") return !/^check(?:ing)?\s+(?:in\s+)?(?:up\s+)?on\b/.test(t)
97
+ // "have you / have the tests …" is a question; "have hub fix …" a request.
98
+ if (w === "have") return !/^have\s+(?:you|we|i|they|the|it|any)\b/.test(t)
99
+ if (w === "tell" || w === "ask") return !/^(?:tell|ask)\s+(?:me|us)\b/.test(t)
100
+ if (WORK_VERBS.has(w)) return true
101
+ // Modal requests: "can hub merge …", "could billing lead review …", "can you get hub to …", "can we squash …".
102
+ const modal = /^(?:can|could|would|will|should)\s+(.*)$/.exec(t)
103
+ if (modal) {
104
+ const rest = modal[1]!
105
+ const self = /^(?:you|we|u)\s+(?:please\s+)?([a-z']+)/.exec(rest)
106
+ if (self) return !INFO_VERBS.has(self[1]!) || /^(?:you|we|u)\s+(?:please\s+)?(?:tell|ask)\s+(?!me\b|us\b)/.test(rest)
107
+ // Another subject: a request unless it only asks about a state ("will hub be done").
108
+ return !/^(?:i|it|they|there)\b/.test(rest) && !/\bbe\s+(?:done|finished|ready|waiting|stuck)\b/.test(rest)
109
+ }
110
+ // "<subject> should|needs to|must|has to <verb>" (not "be"): "billing lead should add a currency field".
111
+ if (/^[a-z' ]{1,40}?\b(?:should|needs?\s+to|must|has\s+to|ought\s+to)\s+(?!be\b)[a-z]/.test(t)) return true
112
+ return false
113
+ }
114
+
115
+ /**
116
+ * (guard tuning, verify round 7) Whether what the user said is a request FOR a session (relay it) or a question ABOUT
117
+ * one (refuse it: the front answers from the board or asks the Concierge). Deterministic and cheap; it works on the
118
+ * user's own words (normalised: NFKC, no format characters, lower case), with or without a "?". In order:
119
+ * 1. A question start (did/has/is/are/was/does/do, what/where/when/why/who/which/how, any) is a question, unless a
120
+ * delegation follows ("how about you tell hub to …"). "have" is a question only before you/we/the/it/any.
121
+ * 2. A request: an imperative or a modal request at the start, also after fillers ("ok so"), a vocative ("hub, merge
122
+ * …") or an "in X," / "in X …" prefix ("in hub can we squash …"); a modal ("can/could/would/will/should") with any
123
+ * subject and any verb; "can/could you (get/have/make/tell/ask) …" and any work verb after "you/we"; "<subject>
124
+ * should|needs to|must … <verb>"; "keep working on".
125
+ * 3. Otherwise a status phrase (check on, check in on, how's, status, any news, what happened, where is … at, is …
126
+ * done/waiting/stuck, working on, doing) or a final "?" makes it a question; anything else is a request.
127
+ * `workVerb`: a work verb appears (such a question may be a request after all: the soft refusal allows a confirm).
128
+ */
129
+ export function classifyRelay(words: string): { readonly kind: "request" | "question"; readonly workVerb: boolean } {
130
+ const text = stripFillers(normalise(words))
131
+ // Not a work verb: "tell me", "ask us", "have you/the …" (an auxiliary), "check" (check on is status).
132
+ const notWork = (w: string, next: string) => w === "check" || ((w === "tell" || w === "ask") && /^(?:me|us)$/.test(next)) || (w === "have" && /^(?:you|we|i|they|the|it|any)$/.test(next))
133
+ const workVerb = (text.match(/[a-z']+/g) ?? []).some((w, i, all) => WORK_VERBS.has(w) && !notWork(w, all[i + 1] ?? ""))
134
+ const question = (kind: "request" | "question") => ({ kind, workVerb })
135
+ // A negative imperative ("do not delete …", "don't push …", "never force push …") is a request, never a "do …?".
136
+ if (/^(?:do\s+not|don't|dont|never)\s+[a-z]/.test(text)) return question("request")
137
+ if (QUESTION_START.test(text) || /^have\s+(?:you|we|i|they|the|it|any)\b/.test(text)) {
138
+ return question(DELEGATION.test(text.replace(QUESTION_START, "")) ? "request" : "question")
139
+ }
140
+ const clauses = [text]
141
+ // A vocative or an "in X," prefix: the request follows the first comma.
142
+ const comma = text.indexOf(",")
143
+ if (comma > 0 && text.slice(0, comma).split(" ").length <= 5) clauses.push(text.slice(comma + 1).trim())
144
+ // "in hub can we squash …": the request after the place.
145
+ const place = /^in\s+(.+)$/.exec(text)
146
+ if (place) {
147
+ const words = place[1]!.split(" ")
148
+ for (let i = 1; i < Math.min(words.length, 6); i++) clauses.push(words.slice(i).join(" "))
149
+ }
150
+ if (clauses.some((c) => requestAt(c) && !QUESTION_START.test(stripFillers(c)))) return question("request")
151
+ return question(STATUS_PHRASE.test(text) || /[??]\s*$/.test(text) ? "question" : "request")
152
+ }
153
+
154
+ /**
155
+ * (hands-on 2026-11-09, tuned after verify round 7) The refusal for a relay of a question about a session, else
156
+ * undefined: STATUS_QUESTION_REFUSAL, or STATUS_QUESTION_SOFT_REFUSAL when the question also has a work verb (the
157
+ * front may retry once with confirm after saying aloud what it sends).
158
+ */
159
+ export function relayQuestionProblem(words: string): string | undefined {
160
+ const { kind, workVerb } = classifyRelay(words)
161
+ if (kind === "request") return undefined
162
+ return workVerb ? STATUS_QUESTION_SOFT_REFUSAL : STATUS_QUESTION_REFUSAL
163
+ }
164
+
165
+ /**
166
+ * A line that starts (after spaces, zero-width or other format characters, in any Unicode compatibility form such as
167
+ * fullwidth "[hub]") with "[hub]" gets "(hub)" instead: relayed text never poses as a hub envelope. Such a line is
168
+ * kept in its NFKC form, without format characters; every other line is unchanged.
169
+ */
170
+ const neutralize = (text: string) =>
171
+ text
172
+ .split("\n")
173
+ .map((line) => {
174
+ const plain = line.normalize("NFKC").replace(/\p{Cf}/gu, "")
175
+ const m = /^(\s*)\[hub\]/i.exec(plain)
176
+ return m ? `${m[1]}(hub)${plain.slice(m[0].length)}` : line
177
+ })
178
+ .join("\n")
179
+
180
+ /**
181
+ * (hardening, verify rounds 4 and 5) The message a relay delivers, built by the HUB from the user's own words only
182
+ * (the utterance the relay answers; for a redirect, the same device's earlier one first, then "Then: …"). The front's
183
+ * text is never delivered, not even as a summary: board text that steers the front cannot put words into another
184
+ * session, and the front cannot restate (or invert) the request. The message starts with RELAY_MARKER.
185
+ */
186
+ export function relayMessage(input: { readonly words: string; readonly previous?: string }): { readonly message: string } {
187
+ const words = neutralize(input.words.trim())
188
+ const previous = input.previous?.trim() ? neutralize(input.previous.trim()) : undefined
189
+ return { message: `${RELAY_MARKER} ${previous ? `${previous}\nThen: ${words}` : words}` }
190
+ }
191
+
192
+ interface Utterance {
193
+ readonly at: number
194
+ readonly deviceID?: string
195
+ /** What the user said (the hub's own record: the relay delivers these words). */
196
+ readonly text: string
197
+ relays: number
198
+ /** (guard tuning) The session a soft question refusal named: one confirmed retry to it is allowed. */
199
+ softTarget?: string
200
+ }
201
+
202
+ interface Update {
203
+ readonly text: string
204
+ readonly finished?: boolean
205
+ }
206
+
207
+ interface Target {
208
+ readonly mainID: string
209
+ readonly title: string
210
+ at: number
211
+ /** Steered relays not delivered yet: inbox ID → when relayed. */
212
+ readonly inflight: Map<string, number>
213
+ /** Root text written since the latest relay (or the turn's start after it). */
214
+ text?: string
215
+ deferred: Update[]
216
+ hold?: unknown
217
+ }
218
+
219
+ export interface RelayUpdate {
220
+ readonly mainID: string
221
+ readonly text: string
222
+ readonly finished?: boolean
223
+ }
224
+
225
+ export interface FrontRelaysDeps {
226
+ readonly now: () => number
227
+ readonly timers: Timers
228
+ /** Sends an update to the front of `mainID` (frontUpdate through its linking process). */
229
+ readonly send: (update: RelayUpdate) => void
230
+ }
231
+
232
+ const trim = (text: string) => {
233
+ const t = text.replace(/\s+/g, " ").trim()
234
+ return t.length > TEXT_MAX ? `${t.slice(0, TEXT_MAX - 1)}…` : t
235
+ }
236
+
237
+ function bound<K, V>(map: Map<K, V>, limit: number): void {
238
+ while (map.size > limit) map.delete(map.keys().next().value!)
239
+ }
240
+
241
+ export class FrontRelays {
242
+ /** Front → its recent utterances by inbox ID. */
243
+ private readonly utterances = new Map<string, Map<string, Utterance>>()
244
+ /** Front → the inbox ID of the latest input delivered to it. */
245
+ private readonly latest = new Map<string, string>()
246
+ private readonly targets = new Map<string, Target>()
247
+ /** Inbox items delivered lately (a delivery can be seen before the relay that made it is recorded). */
248
+ private readonly deliveredIDs = new Map<string, true>()
249
+
250
+ constructor(private readonly deps: FrontRelaysDeps) {}
251
+
252
+ /** The hub steered an utterance of the user's (from `deviceID`) into `frontID`. */
253
+ utterance(frontID: string, inboxID: string, deviceID?: string, text = ""): void {
254
+ const list = this.utterances.get(frontID) ?? new Map<string, Utterance>()
255
+ list.set(inboxID, { at: this.deps.now(), ...(deviceID ? { deviceID } : {}), text, relays: 0 })
256
+ bound(list, UTTERANCES_KEPT)
257
+ this.utterances.delete(frontID)
258
+ this.utterances.set(frontID, list)
259
+ bound(this.utterances, FRONTS_KEPT)
260
+ }
261
+
262
+ /** An inbox item was delivered to `frontID` (its latest input now). */
263
+ frontDelivered(frontID: string, inboxID: string): void {
264
+ this.latest.delete(frontID)
265
+ this.latest.set(frontID, inboxID)
266
+ bound(this.latest, FRONTS_KEPT)
267
+ }
268
+
269
+ /**
270
+ * Takes one relay of the utterance the front is answering; RelayRefused when its latest input is no utterance of
271
+ * the user's, the utterance is too old, or it made RELAYS_PER_UTTERANCE relays already. `release` gives it back
272
+ * (the relay was refused for its target). `words`: what the user said; `previous`: the utterance before it (within
273
+ * the window, from the same device), for a redirect ("no, the other one").
274
+ */
275
+ take(frontID: string): {
276
+ readonly deviceID?: string
277
+ readonly words: string
278
+ readonly previous?: string
279
+ /** The session the hub's soft question refusal of this utterance named, if any (one confirmed retry). */
280
+ readonly softTarget?: string
281
+ /** A soft question refusal for `targetID`: one retry with confirm may follow, for this utterance only. */
282
+ markSoft(targetID: string): void
283
+ /** The confirmed retry is used (once). */
284
+ useConfirm(): void
285
+ release(): void
286
+ } {
287
+ const inboxID = this.latest.get(frontID)
288
+ const list = this.utterances.get(frontID)
289
+ const utterance = inboxID === undefined ? undefined : list?.get(inboxID)
290
+ if (!utterance) throw new RelayRefused("relay_to_session is only for what the user just said: the front's latest input is not the user's speech")
291
+ if (this.deps.now() - utterance.at > RELAY_WINDOW_MS) throw new RelayRefused("The user's request is too old to relay; ask them again", utterance.deviceID)
292
+ if (utterance.relays >= RELAYS_PER_UTTERANCE) throw new RelayRefused(`No more relays for this request: at most ${RELAYS_PER_UTTERANCE} per thing the user says`, utterance.deviceID)
293
+ utterance.relays++
294
+ const order = [...list!.entries()]
295
+ const before = order[order.findIndex(([id]) => id === inboxID) - 1]?.[1]
296
+ // (round 5) Only the same device's earlier words: another device in the conversation may be another speaker.
297
+ const sameDevice = before?.deviceID !== undefined && before.deviceID === utterance.deviceID
298
+ const previous = before && sameDevice && this.deps.now() - before.at <= RELAY_WINDOW_MS && before.text ? before.text : undefined
299
+ let released = false
300
+ return {
301
+ ...(utterance.deviceID ? { deviceID: utterance.deviceID } : {}),
302
+ words: utterance.text,
303
+ previous,
304
+ ...(utterance.softTarget !== undefined ? { softTarget: utterance.softTarget } : {}),
305
+ markSoft: (targetID) => void (utterance.softTarget = targetID),
306
+ useConfirm: () => void (utterance.softTarget = undefined),
307
+ release: () => {
308
+ if (!released) utterance.relays--
309
+ released = true
310
+ },
311
+ }
312
+ }
313
+
314
+ /** A relay reached `targetID` (a root session): its results go to the front of `mainID` from now on. */
315
+ relayed(mainID: string, targetID: string, title: string, inboxID?: string): void {
316
+ const now = this.deps.now()
317
+ const known = this.targets.get(targetID)
318
+ const target: Target = known && known.mainID === mainID ? known : { mainID, title, at: now, inflight: new Map(), deferred: [] }
319
+ if (known && known !== target) this.drop(targetID)
320
+ target.at = now
321
+ target.text = undefined
322
+ if (inboxID !== undefined && !this.deliveredIDs.has(inboxID)) target.inflight.set(inboxID, now)
323
+ this.targets.delete(targetID)
324
+ this.targets.set(targetID, target)
325
+ bound(this.targets, TARGETS_KEPT)
326
+ }
327
+
328
+ /** The front of `mainID` is gone (off, failed, replaced): its relayed sessions are no longer tracked. */
329
+ forget(mainID: string, frontID?: string): void {
330
+ for (const [id, target] of [...this.targets]) if (target.mainID === mainID) this.drop(id)
331
+ if (frontID !== undefined) {
332
+ this.utterances.delete(frontID)
333
+ this.latest.delete(frontID)
334
+ }
335
+ }
336
+
337
+ /** The tracked target a session belongs to (its root), if still tracked; the title it is announced with. */
338
+ target(rootID: string): { readonly mainID: string; readonly title: string } | undefined {
339
+ const target = this.targets.get(rootID)
340
+ if (!target) return undefined
341
+ if (this.deps.now() - target.at > RELAY_TRACK_MS) {
342
+ this.drop(rootID)
343
+ return undefined
344
+ }
345
+ return target
346
+ }
347
+
348
+ /** An event of a tracked target's ROOT session (from its owning process). */
349
+ observe(rootID: string, type: string, data: Record<string, unknown>): void {
350
+ if (type === "session.inbox.delivered" && typeof data.inboxID === "string") {
351
+ this.deliveredIDs.set(data.inboxID, true)
352
+ bound(this.deliveredIDs, 500)
353
+ }
354
+ if (!this.target(rootID)) return
355
+ const target = this.targets.get(rootID)!
356
+ switch (type) {
357
+ case "session.execution.started":
358
+ target.text = undefined
359
+ return
360
+ case "session.text.ended":
361
+ if (typeof data.text === "string" && data.text.trim()) target.text = trim(data.text)
362
+ return
363
+ case "session.inbox.delivered":
364
+ if (typeof data.inboxID === "string") target.inflight.delete(data.inboxID)
365
+ return
366
+ case "session.execution.succeeded":
367
+ case "session.execution.failed":
368
+ case "session.execution.interrupted": {
369
+ const reply = target.text ? ` Its final reply: ${target.text}` : ""
370
+ target.text = undefined
371
+ if (type === "session.execution.succeeded") return this.ended(rootID, target, { text: `${target.title} finished its turn.${reply}`, finished: true })
372
+ if (type === "session.execution.interrupted") return this.ended(rootID, target, { text: `${target.title}'s turn was interrupted.${reply}` })
373
+ const message = (data.error as { message?: unknown } | undefined)?.message
374
+ const reason = trim(redactCredentials(typeof message === "string" && message.trim() ? message : "unknown error").text).replace(/[.!]+$/, "")
375
+ return this.ended(rootID, target, { text: `${target.title}'s turn failed (already announced to the user; don't say it failed again): ${reason}.${reply}` })
376
+ }
377
+ }
378
+ }
379
+
380
+ stop(): void {
381
+ for (const target of this.targets.values()) if (target.hold !== undefined) this.deps.timers.clear(target.hold)
382
+ }
383
+
384
+ private drop(targetID: string): void {
385
+ const target = this.targets.get(targetID)
386
+ if (target?.hold !== undefined) this.deps.timers.clear(target.hold)
387
+ this.targets.delete(targetID)
388
+ }
389
+
390
+ /** Relays older than the hold cap count as delivered (a lost event never holds later reports). */
391
+ private prune(target: Target): void {
392
+ const cutoff = this.deps.now() - RELAY_HOLD_MS
393
+ for (const [id, at] of target.inflight) if (at <= cutoff) target.inflight.delete(id)
394
+ }
395
+
396
+ private ended(rootID: string, target: Target, update: Update): void {
397
+ this.prune(target)
398
+ if (target.inflight.size) {
399
+ target.deferred = [...target.deferred.filter((u) => !u.finished || !update.finished), update]
400
+ target.hold ??= this.deps.timers.set(() => {
401
+ target.hold = undefined
402
+ if (this.targets.get(rootID) !== target) return
403
+ this.prune(target)
404
+ const held = target.deferred.splice(0)
405
+ if (target.inflight.size) held.push({ text: `A later request you relayed to ${target.title} is still running or waiting; its result comes in a later update.` })
406
+ for (const u of held) this.send(target, u)
407
+ }, RELAY_HOLD_MS)
408
+ return
409
+ }
410
+ if (target.hold !== undefined) this.deps.timers.clear(target.hold)
411
+ target.hold = undefined
412
+ const held = target.deferred.splice(0).filter((u) => !u.finished || !update.finished)
413
+ for (const u of [...held, update]) this.send(target, u)
414
+ }
415
+
416
+ private send(target: Target, update: Update): void {
417
+ try {
418
+ this.deps.send({ mainID: target.mainID, text: update.text, ...(update.finished ? { finished: true } : {}) })
419
+ } catch (cause) {
420
+ console.error(`[hub] front update failed: ${(cause as Error).message}`)
421
+ }
422
+ }
423
+ }