@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.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +6 -6
- package/packages/cli/package.json +1 -1
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +7 -2
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/registry.ts +93 -22
- package/src/index.ts +28 -0
- package/src/spoke/spoke.ts +150 -10
- package/src/status.ts +70 -14
- package/web/dist/assets/{ArtifactPage-CSotAWob.js → ArtifactPage-Cb1DcrQf.js} +1 -1
- package/web/dist/assets/{ArtifactPane-DPHjiWce.js → ArtifactPane-XTnwXl-Y.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-CkmWnP6L.js → ArtifactWorkbench-C37tZZ3e.js} +1 -1
- package/web/dist/assets/{Library-DTLEyekn.js → Library-CZSSHvOJ.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-GriuOpiZ.js → ProfilesDestination-dZa3vioh.js} +1 -1
- package/web/dist/assets/{TeamsPage-C-5lYeeM.js → TeamsPage-DJt-Kk84.js} +1 -1
- package/web/dist/assets/{Watchtower-DOlPsiIy.js → Watchtower-BXaUaXO1.js} +2 -2
- package/web/dist/assets/{index-C5OmwA1y.css → index-DVSkeAvn.css} +1 -1
- package/web/dist/assets/{index-6mdQ7eUY.js → index-DifgVuKS.js} +22 -22
- package/web/dist/assets/{live-CDWcyavI.js → live-D-RZbeUi.js} +1 -1
- package/web/dist/assets/{renderer2d-CNFdTDTr.js → renderer2d-DILxLFEq.js} +1 -1
- package/web/dist/assets/{renderer3d-C45jPvYX.js → renderer3d-PvN9Qbt2.js} +1 -1
- package/web/dist/assets/{safe-area-sA9a8vYJ.js → safe-area-BS6ii3M7.js} +1 -1
- 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
|
+
"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
|
+
"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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-codex",
|
|
3
|
-
"version": "0.8.
|
|
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
|
+
"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
|
+
"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",
|
|
@@ -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
|
-
|
|
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
|
+
"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-spoke-sdk",
|
|
3
|
-
"version": "0.8.
|
|
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",
|
package/src/central/registry.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
742
|
-
this.
|
|
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)
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
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
|
? {
|