@huynle/hub 0.8.3 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/packages/claude-code/package.json +1 -1
  4. package/packages/cli/dist/hub-cli.js +6 -6
  5. package/packages/cli/package.json +1 -1
  6. package/packages/codex/package.json +1 -1
  7. package/packages/node/package.json +1 -1
  8. package/packages/preset/package.json +1 -1
  9. package/packages/protocol/package.json +1 -1
  10. package/packages/protocol/src/index.ts +7 -2
  11. package/packages/runtime-openshell/package.json +1 -1
  12. package/packages/runtime-sdk/package.json +1 -1
  13. package/packages/sdk/package.json +1 -1
  14. package/packages/spoke-sdk/package.json +1 -1
  15. package/src/central/registry.ts +93 -22
  16. package/src/index.ts +28 -0
  17. package/src/spoke/spoke.ts +150 -10
  18. package/src/status.ts +70 -14
  19. package/web/dist/assets/{ArtifactPage-CSotAWob.js → ArtifactPage-Cb1DcrQf.js} +1 -1
  20. package/web/dist/assets/{ArtifactPane-DPHjiWce.js → ArtifactPane-XTnwXl-Y.js} +1 -1
  21. package/web/dist/assets/{ArtifactWorkbench-CkmWnP6L.js → ArtifactWorkbench-C37tZZ3e.js} +1 -1
  22. package/web/dist/assets/{Library-DTLEyekn.js → Library-CZSSHvOJ.js} +2 -2
  23. package/web/dist/assets/{ProfilesDestination-GriuOpiZ.js → ProfilesDestination-dZa3vioh.js} +1 -1
  24. package/web/dist/assets/{TeamsPage-C-5lYeeM.js → TeamsPage-DJt-Kk84.js} +1 -1
  25. package/web/dist/assets/{Watchtower-DOlPsiIy.js → Watchtower-BXaUaXO1.js} +2 -2
  26. package/web/dist/assets/{index-C5OmwA1y.css → index-DVSkeAvn.css} +1 -1
  27. package/web/dist/assets/{index-6mdQ7eUY.js → index-DifgVuKS.js} +22 -22
  28. package/web/dist/assets/{live-CDWcyavI.js → live-D-RZbeUi.js} +1 -1
  29. package/web/dist/assets/{renderer2d-CNFdTDTr.js → renderer2d-DILxLFEq.js} +1 -1
  30. package/web/dist/assets/{renderer3d-C45jPvYX.js → renderer3d-PvN9Qbt2.js} +1 -1
  31. package/web/dist/assets/{safe-area-sA9a8vYJ.js → safe-area-BS6ii3M7.js} +1 -1
  32. package/web/dist/index.html +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.4
4
+
5
+ A fix release. A question or permission a session is waiting on no longer disappears from the console while
6
+ OpenCode still waits (after the session briefly left a machine's list, or after a reconnect): the hub keeps a bounded
7
+ card for it and puts it back, and the machine re-announces what is pending after every reconnect. "Needs you" and the
8
+ hub now agree; a terminal Herdr reports blocked with nothing pending on the hub shows as "waiting in terminal".
9
+ No migration is needed; machines need the 0.8.4 plugin for the reconnect part.
10
+
11
+ ### Fixes
12
+ - **The hub drops a question or permission whose ID is longer than any card keeps** (200 characters), so no such
13
+ entry is stored or alerted.
14
+ - **A pending question or permission no longer vanishes from the console while OpenCode still waits**
15
+ (production 2026-11-05: a session's question card disappeared, `show` said "Nothing waiting", the session still ran
16
+ in the question tool). The hub kept blockers only on a session's status board, built from live events: a board
17
+ recreated after the session left a sessions frame for a moment started empty, and a reconnecting spoke (laptop
18
+ sleep, network change, a restarted OpenCode process or hub) lost every pending question with the old connection and
19
+ never announced it again. Now the hub puts pending questions and permissions back on recreated boards (and on
20
+ boards created after the question was asked), the form stays answerable, and the spoke re-announces what is pending
21
+ after each reconnect: what it saw asked, plus what OpenCode lists for its active sessions (pending forms and
22
+ permission requests from the process's own server), closing those OpenCode no longer has. A re-announced question
23
+ or permission is not notified twice. Tests: `test/central-registry-blockers.test.ts`, `test/e2e-lost-question.test.ts`,
24
+ the spoke's reconnect tests and the browser suite `web/e2e/question-survives.ts`.
25
+ - What the hub keeps of a pending question or permission is the bounded card (at most 20 fields, the text limits,
26
+ 10 resources; at most 64 kB, over which descriptions and then fields are left out; IDs over 200 characters are
27
+ refused), at most 200 per user for each kind. A permission card's summary is now bounded too (it joined every
28
+ resource). Another user can no longer close a pending question or permission by its ID, nor push it out with a
29
+ flood of its own.
30
+ - The spoke re-sends what it remembers right after reconnecting, then asks OpenCode (at most 50 sessions, the ones
31
+ with something remembered first, 5 s at most). Closes it infers from OpenCode's lists carry `unlisted: true`: the
32
+ hub drops only that process's hold on the question.
33
+ - Mixed versions: the hub announces the new `pendingReplay` feature. To an older hub (no such feature, and no
34
+ dedupe of re-sent permissions) the spoke re-sends only questions, so permissions it told that hub already are
35
+ lost on a reconnect as before rather than notified twice; a question lost from a frame drop is still lost on an
36
+ older hub. An older spoke on a new hub behaves as before (lost on reconnect).
37
+ - **"Needs you" only when there is something to answer.** A session that only Herdr calls blocked (its agent waits
38
+ for input, the hub has no question or permission for it) is no longer under Needs you or counted there: its row
39
+ shows "waiting in terminal" and it counts as working.
40
+
3
41
  ## 0.8.3
4
42
 
5
43
  A voice and security release. A new developer setting records voice conversations (the Concierge and any voice
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
5
5
  "license": "MIT",
6
6
  "author": "Huy Le",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-claude-code",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "description": "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -7,7 +7,7 @@ import { createInterface } from "readline";
7
7
  // packages/cli/package.json
8
8
  var package_default = {
9
9
  name: "@huynle/hub-cli",
10
- version: "0.8.3",
10
+ version: "0.8.4",
11
11
  private: true,
12
12
  description: "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
13
13
  license: "MIT",
@@ -8815,7 +8815,7 @@ import { arch as osArch, platform as osPlatform } from "node:os";
8815
8815
  // packages/node/package.json
8816
8816
  var package_default2 = {
8817
8817
  name: "@huynle/hub-node",
8818
- version: "0.8.3",
8818
+ version: "0.8.4",
8819
8819
  private: true,
8820
8820
  description: "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
8821
8821
  license: "MIT",
@@ -9907,7 +9907,7 @@ import path13 from "node:path";
9907
9907
  // packages/runtime-openshell/package.json
9908
9908
  var package_default3 = {
9909
9909
  name: "@huynle/hub-runtime-openshell",
9910
- version: "0.8.3",
9910
+ version: "0.8.4",
9911
9911
  private: true,
9912
9912
  description: "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
9913
9913
  license: "MIT",
@@ -13591,7 +13591,7 @@ import path22 from "node:path";
13591
13591
  // packages/claude-code/package.json
13592
13592
  var package_default4 = {
13593
13593
  name: "@huynle/hub-claude-code",
13594
- version: "0.8.3",
13594
+ version: "0.8.4",
13595
13595
  private: true,
13596
13596
  description: "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
13597
13597
  license: "MIT",
@@ -13880,7 +13880,7 @@ import path25 from "node:path";
13880
13880
  // packages/preset/package.json
13881
13881
  var package_default5 = {
13882
13882
  name: "@huynle/hub-preset",
13883
- version: "0.8.3",
13883
+ version: "0.8.4",
13884
13884
  private: true,
13885
13885
  description: "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
13886
13886
  license: "MIT",
@@ -14405,7 +14405,7 @@ import { openSync as openSync5, closeSync as closeSync5, readFileSync as readFil
14405
14405
  // packages/codex/package.json
14406
14406
  var package_default6 = {
14407
14407
  name: "@huynle/hub-codex",
14408
- version: "0.8.3",
14408
+ version: "0.8.4",
14409
14409
  private: true,
14410
14410
  description: "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
14411
14411
  license: "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-cli",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "description": "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-node",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-preset",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-protocol",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "description": "Hub ↔ spoke wire protocol (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -482,10 +482,15 @@ export const SPOKE_FEATURES: readonly SpokeFeature[] = [...OPTIONAL_SPOKE_FEATUR
482
482
  * "nodes": it accepts a node hello (role "node", see node.ts) and sends node ops; a node backs off from a hub
483
483
  * without it. "launches": it launches sessions (it sends launch creates to spokes that announce "launch").
484
484
  */
485
- export const KNOWN_HUB_FEATURES = ["harness", "messaging", "nodes", "launches"] as const
485
+ /**
486
+ * "pendingReplay": the hub takes a re-announced permission.asked / form.created (after a spoke reconnects) without
487
+ * notifying again, and honours `unlisted: true` on form.cancelled / permission.replied (only that process stops
488
+ * holding it). Spokes re-send permissions the hub was told already only to a hub that announces it.
489
+ */
490
+ export const KNOWN_HUB_FEATURES = ["harness", "messaging", "nodes", "launches", "pendingReplay"] as const
486
491
  export type HubFeature = (typeof KNOWN_HUB_FEATURES)[number]
487
492
  /** What this hub announces ("launches": the /api/agent/launches routes). */
488
- export const HUB_FEATURES: readonly HubFeature[] = ["harness", "messaging", "nodes", "launches"]
493
+ export const HUB_FEATURES: readonly HubFeature[] = ["harness", "messaging", "nodes", "launches", "pendingReplay"]
489
494
 
490
495
  /** A spoke's harness, version and evidence level; a hello without `harness` is an OpenCode native spoke. */
491
496
  export function helloHarness(hello: Hello): { readonly harness: string; readonly harnessVersion: string; readonly evidence: EvidenceLevel | "unknown" } {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-openshell",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-sdk",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "description": "The Runtime interface `hub node` runtimes implement (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-sdk",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "description": "Typed client for the hub's /api/agent (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@
12
12
  // holds the connection; remote state is never re-exported.
13
13
  import path from "node:path"
14
14
  import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type RevertCommitted, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
15
- import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
15
+ import { BLOCKER_ID_MAX, StatusBoard, askBlocker, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
16
16
  import { sanitizeLabel } from "./agent/envelope"
17
17
  import { DoneWatch } from "./done"
18
18
  import { SentenceStream } from "./say-stream"
@@ -187,6 +187,17 @@ interface PendingPermission {
187
187
  readonly tenant?: string
188
188
  readonly request?: PermissionRequest
189
189
  recommendation?: BlockerRecommendation
190
+ /** Its blocker (bounded, askBlocker), to put back on a status board that lost it (see restoreBlockers). */
191
+ readonly blocker?: Blocker
192
+ }
193
+
194
+ /** A pending form and the processes that forwarded it. */
195
+ interface PendingForm {
196
+ connIDs: string[]
197
+ readonly sessionID: string
198
+ readonly tenant?: string
199
+ /** Its blocker (bounded, askBlocker), to put back on a status board that lost it (see restoreBlockers). */
200
+ readonly blocker?: Blocker
190
201
  }
191
202
 
192
203
  /** Characters of a recommendation's author and reason on a blocker. */
@@ -399,6 +410,8 @@ interface WorkerSpeech {
399
410
 
400
411
  /** How many spoken texts, utterances and early deliveries are remembered for dedupe and matching. */
401
412
  const MEMORY = 1000
413
+ /** Pending permissions, and pending forms, kept per tenant (oldest dropped). */
414
+ export const PENDING_PER_TENANT = 200
402
415
 
403
416
  /** Adds to a bounded insertion-ordered map, dropping the oldest entry when full. */
404
417
  function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void {
@@ -406,6 +419,12 @@ function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void
406
419
  if (map.size > limit) map.delete(map.keys().next().value!)
407
420
  }
408
421
 
422
+ /** Adds an ID to a bounded set of IDs (oldest dropped). */
423
+ function rememberID(set: Set<string>, id: string, limit = 1000): void {
424
+ set.add(id)
425
+ if (set.size > limit) set.delete(set.values().next().value!)
426
+ }
427
+
409
428
  const eventSessionID = (event: ForwardedEvent): string | undefined => {
410
429
  const data = event.data as Record<string, any>
411
430
  const id = event.type === "form.created" ? data.form?.sessionID : data.sessionID
@@ -440,9 +459,11 @@ export class Registry {
440
459
  private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
441
460
  private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
442
461
  /** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
443
- private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
444
- /** Form IDs already alerted (forms may be forwarded by several processes). */
462
+ private readonly forms = new Map<string, PendingForm>()
463
+ /** Form IDs already alerted (forms may be forwarded by several processes, and re-announced after a reconnect). */
445
464
  private readonly alertedForms = new Set<string>()
465
+ /** Permission IDs already alerted (a reconnecting spoke re-announces what is still pending). */
466
+ private readonly alertedPermissions = new Set<string>()
446
467
  /** Sessions whose current run raised a providerError notification (once per run; cleared when a run starts). */
447
468
  private readonly providerAlerted = new Set<string>()
448
469
  /** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
@@ -719,6 +740,8 @@ export class Registry {
719
740
  const owner = this.sessionTenant(id)
720
741
  if (owner !== undefined && owner !== tenant) return
721
742
  }
743
+ // A question or permission whose ID no card can carry is never kept (no card, no answer; unbounded memory).
744
+ if (oversizedBlockerID(event.type, data)) return
722
745
  if (sessionID) conn.activity.set(sessionID, this.tick())
723
746
  if (sessionID && event.type.startsWith("session.execution.")) {
724
747
  if (event.type === "session.execution.interrupted" && data.inferred === true) remember(this.inferredEnds, sessionID, this.now())
@@ -733,34 +756,46 @@ export class Registry {
733
756
  if (event.type === "permission.asked" && typeof data.id === "string" && sessionID) {
734
757
  const known = this.permissions.get(data.id)
735
758
  if (known) {
736
- // The same request forwarded by another process: one more place to answer it.
759
+ // The same request forwarded by another process (or re-announced): one more place to answer it, and its
760
+ // blocker back on a status board that lost it.
737
761
  if (!known.connIDs.includes(connID)) known.connIDs.push(connID)
762
+ this.observeBoards(event, tenant)
738
763
  return
739
764
  }
740
765
  const request = permissionRequest(data)
741
- this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}) })
742
- this.alert("permission", sessionID, { requestID: data.id })
766
+ const blocker = askBlocker(event)
767
+ this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}), ...(blocker ? { blocker } : {}) })
768
+ this.capPending(this.permissions, tenant, (id) => this.dropPermission(id))
769
+ if (!this.alertedPermissions.has(data.id) && this.alert("permission", sessionID, { requestID: data.id })) rememberID(this.alertedPermissions, data.id)
743
770
  }
744
771
  if (event.type === "form.created" && sessionID) {
745
772
  const formID = (data.form as Record<string, unknown> | undefined)?.id
746
773
  if (typeof formID === "string") {
747
774
  const known = this.forms.get(formID)
748
775
  if (known && !known.connIDs.includes(connID)) known.connIDs.push(connID)
749
- else if (!known) remember(this.forms, formID, { connIDs: [connID], sessionID })
750
- }
751
- if (typeof formID === "string" && !this.alertedForms.has(formID)) {
752
- this.alertedForms.add(formID)
753
- if (this.alertedForms.size > 1000) this.alertedForms.delete(this.alertedForms.values().next().value!)
754
- this.alert("question", sessionID)
776
+ else if (!known) {
777
+ const blocker = askBlocker(event)
778
+ this.forms.set(formID, { connIDs: [connID], sessionID, tenant, ...(blocker ? { blocker } : {}) })
779
+ this.capPending(this.forms, tenant, (id) => this.dropForm(id))
780
+ }
755
781
  }
782
+ if (typeof formID === "string" && !this.alertedForms.has(formID) && this.alert("question", sessionID)) rememberID(this.alertedForms, formID)
756
783
  }
757
784
  let closedPermission: [string, PendingPermission] | undefined
758
- if (event.type === "permission.replied" && typeof data.requestID === "string") {
759
- const origin = this.permissions.get(data.requestID)
760
- if (origin && this.permissions.delete(data.requestID)) closedPermission = [data.requestID, origin]
761
- }
762
- if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string") {
763
- this.forms.delete(data.id)
785
+ const permissionClose = event.type === "permission.replied" && typeof data.requestID === "string"
786
+ const formClose = (event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string"
787
+ if (permissionClose || formClose) {
788
+ const id = (permissionClose ? data.requestID : data.id) as string
789
+ const map: Map<string, PendingPermission | PendingForm> = permissionClose ? this.permissions : this.forms
790
+ const origin = map.get(id)
791
+ // Only the tenant that asked closes it (IDs are not secret enough to let another tenant end it).
792
+ const own = origin !== undefined && (origin.tenant === undefined || origin.tenant === tenant)
793
+ if (own && data.unlisted === true) {
794
+ // A spoke found it no longer listed by its own harness: only that process stops holding it.
795
+ origin.connIDs = origin.connIDs.filter((c) => c !== connID)
796
+ if (origin.connIDs.length) return
797
+ }
798
+ if (own && map.delete(id) && permissionClose) closedPermission = [id, origin as PendingPermission]
764
799
  }
765
800
 
766
801
  if (sessionID) this.trackRun(event.type, sessionID)
@@ -1951,6 +1986,33 @@ export class Registry {
1951
1986
  if (next.length === pending.length) break
1952
1987
  pending = next
1953
1988
  }
1989
+ this.restoreBlockers()
1990
+ }
1991
+
1992
+ /** Keeps at most PENDING_PER_TENANT entries of the tenant in a pending map: the oldest are dropped (with their blocker). */
1993
+ private capPending(map: Map<string, PendingPermission | PendingForm>, tenant: string, drop: (id: string) => void): void {
1994
+ const own = [...map].filter(([, origin]) => (origin.tenant ?? this.sessionTenant(origin.sessionID)) === tenant)
1995
+ for (const [id] of own.slice(0, Math.max(0, own.length - PENDING_PER_TENANT))) drop(id)
1996
+ }
1997
+
1998
+ /**
1999
+ * Puts pending permissions and forms back on the status boards that lost them: a board recreated after its
2000
+ * session left the reported sessions for a moment, or one created after the question was asked (events reach
2001
+ * only existing boards). OpenCode still waits for them; only a reply, a cancel or their processes going away
2002
+ * end them (production 2026-11-05: a question card vanished while the session still waited).
2003
+ */
2004
+ private restoreBlockers(): void {
2005
+ const restored = new Set<string>()
2006
+ const restore = ({ sessionID, tenant, blocker }: PendingPermission | PendingForm) => {
2007
+ if (!blocker) return
2008
+ const rootID = this.rootOf(sessionID)
2009
+ const board = rootID === undefined ? undefined : this.boards.get(rootID)
2010
+ if (!rootID || !board || (tenant !== undefined && this.sessionTenant(rootID) !== tenant)) return
2011
+ if (board.restoreBlocker(sessionID, blocker)) restored.add(rootID)
2012
+ }
2013
+ for (const origin of this.permissions.values()) restore(origin)
2014
+ for (const origin of this.forms.values()) restore(origin)
2015
+ for (const rootID of restored) this.emit({ type: "status", sessionID: rootID, sessions: this.live(this.boards.get(rootID)!.snapshot().sessions) })
1954
2016
  }
1955
2017
 
1956
2018
  // ---------- cluster (design §9) ----------
@@ -1978,7 +2040,7 @@ export class Registry {
1978
2040
  const add = (tenant: string | undefined) => tenant !== undefined && tenants.add(tenant)
1979
2041
  for (const rootID of this.boards.keys()) add(this.sessionTenant(rootID))
1980
2042
  for (const origin of this.permissions.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
1981
- for (const origin of this.forms.values()) add(this.sessionTenant(origin.sessionID))
2043
+ for (const origin of this.forms.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
1982
2044
  for (const entry of this.presence.values()) add(this.sessionTenant(entry.sessionID))
1983
2045
  for (const sessionID of this.active.keys()) add(this.sessionTenant(sessionID))
1984
2046
  return [...tenants]
@@ -2113,11 +2175,12 @@ export class Registry {
2113
2175
  * Raises a notification for the root of `sessionID`'s tree. Only hand-offs, questions and permissions reach a
2114
2176
  * session some device shows (spoken replies check that themselves: the watching device speaks them).
2115
2177
  */
2116
- private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): void {
2178
+ /** True when it was raised (or skipped because a device shows the session); false for a session on no board. */
2179
+ private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): boolean {
2117
2180
  const entry = [...this.boards].find(([, board]) => board.get(sessionID))
2118
- if (!entry) return
2181
+ if (!entry) return false
2119
2182
  const [rootID, board] = entry
2120
- if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return
2183
+ if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return true
2121
2184
  const alert: Alert = {
2122
2185
  kind,
2123
2186
  sessionID: rootID,
@@ -2130,6 +2193,7 @@ export class Registry {
2130
2193
  }
2131
2194
  const owner = this.sessionTenant(rootID)
2132
2195
  for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
2196
+ return true
2133
2197
  }
2134
2198
 
2135
2199
  private emit(message: RegistryMessage): void {
@@ -2137,3 +2201,10 @@ export class Registry {
2137
2201
  for (const listener of this.listeners) listener(message, tenant)
2138
2202
  }
2139
2203
  }
2204
+
2205
+ /** A permission.asked / form.created (or their closes) with an ID longer than any card keeps. */
2206
+ function oversizedBlockerID(type: string, data: Record<string, unknown>): boolean {
2207
+ const ids =
2208
+ type === "permission.asked" ? [data.id] : type === "form.created" ? [(data.form as Record<string, unknown> | undefined)?.id] : []
2209
+ return ids.some((id) => typeof id === "string" && id.length > BLOCKER_ID_MAX)
2210
+ }
package/src/index.ts CHANGED
@@ -162,6 +162,33 @@ async function activity(kind: "service" | "standalone", directories: readonly st
162
162
  return { active: Object.keys(active ?? {}), jobs }
163
163
  }
164
164
 
165
+ /**
166
+ * What OpenCode waits for in each session on this process's own server: its pending forms (session.form.list) and
167
+ * permission requests (session.permission.list). A session that cannot be read is left out; nothing when the
168
+ * server cannot be reached.
169
+ */
170
+ async function pending(kind: "service" | "standalone", sessionIDs: readonly string[]) {
171
+ const client = await ownClient(kind).catch(() => undefined)
172
+ if (!client) return []
173
+ const out: Array<{ sessionID: string; forms: unknown[]; permissions: unknown[] }> = []
174
+ // One budget for the whole listing (the spoke gives up after 5 s too): a hung server stops being asked.
175
+ const signal = AbortSignal.timeout(PENDING_LIST_TIMEOUT_MS)
176
+ for (const sessionID of sessionIDs) {
177
+ if (signal.aborted) break
178
+ try {
179
+ const [forms, permissions] = await Promise.all([client.session.form.list({ sessionID }, { signal }), client.permission.list({ sessionID }, { signal })])
180
+ const list = (v: unknown) => unwrap<unknown[]>(v, Array.isArray)
181
+ out.push({ sessionID, forms: Array.isArray(list(forms)) ? list(forms) : [], permissions: Array.isArray(list(permissions)) ? list(permissions) : [] })
182
+ } catch {
183
+ // a session this server does not have (any more)
184
+ }
185
+ }
186
+ return out
187
+ }
188
+
189
+ /** How long one listing of pending forms and permissions may take on the own server. */
190
+ const PENDING_LIST_TIMEOUT_MS = 5000
191
+
165
192
  /** Plugin API results may arrive bare or wrapped as { location, data }. */
166
193
  function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
167
194
  const v = value as any
@@ -253,6 +280,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
253
280
  },
254
281
  ...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
255
282
  activity: (directories) => activity(kind, directories),
283
+ pending: (sessionIDs) => pending(kind, sessionIDs),
256
284
  // Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
257
285
  ...(canList(kind)
258
286
  ? {