@huynle/hub 0.6.1 → 0.7.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.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
package/src/central/registry.ts
CHANGED
|
@@ -2,9 +2,25 @@
|
|
|
2
2
|
// StatusBoard per root session (fed by forwarded events from any process), owner selection for turns,
|
|
3
3
|
// a per-session turn lock, permission routing back to the process that asked, and conversation mode
|
|
4
4
|
// (which sessions speak their replies on the phone).
|
|
5
|
+
//
|
|
6
|
+
// Cluster mode (design §9, decision vkxkqiuf §5): the Registry is the single writer of this pod's connections and
|
|
7
|
+
// exports, per tenant, a RegistrySlice of them (connections and what they report, status boards with their activity,
|
|
8
|
+
// pending permissions and forms, presence, sessions with a turn running); ./cluster/view.ts publishes it to the
|
|
9
|
+
// Catalog and the Bus and applies other pods' slices here (setRemote). The synchronous reads (sessionTenant,
|
|
10
|
+
// owner, catalog, permissionTargets, …) answer over local and remote state, so a route on any pod finds a spoke on
|
|
11
|
+
// another one and reaches it through SpokeHub.request (pod RPC). Events, alerts and speech stay with the pod that
|
|
12
|
+
// holds the connection; remote state is never re-exported.
|
|
5
13
|
import path from "node:path"
|
|
6
|
-
import type
|
|
7
|
-
import { StatusBoard, type Blocker, type SessionStatus } from "../status"
|
|
14
|
+
import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
|
|
15
|
+
import { StatusBoard, permissionRequest, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
16
|
+
import { sanitizeLabel } from "./agent/envelope"
|
|
17
|
+
import { DoneWatch } from "./done"
|
|
18
|
+
import { SentenceStream } from "./say-stream"
|
|
19
|
+
import type { ActivityStep, NotificationKind } from "./push"
|
|
20
|
+
|
|
21
|
+
export { ACTIVITY_INTERVAL_MINUTES } from "./store"
|
|
22
|
+
/** A tool call that took at least this long is named in activity updates (shorter ones are only counted). */
|
|
23
|
+
export const LONG_TOOL_MS = 30_000
|
|
8
24
|
|
|
9
25
|
export class BusyError extends Error {
|
|
10
26
|
constructor() {
|
|
@@ -45,13 +61,17 @@ export const STREAM_INTERVAL_MS = 100
|
|
|
45
61
|
export type RegistryMessage =
|
|
46
62
|
| { readonly type: "catalog" }
|
|
47
63
|
| { readonly type: "status"; readonly sessionID: string; readonly sessions: LiveStatus[] }
|
|
48
|
-
/** Assistant text streamed in a root or subagent session (rootID: its root session). */
|
|
49
|
-
| { readonly type: "delta"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly text: string }
|
|
64
|
+
/** Assistant text streamed in a root or subagent session (rootID: its root session; ordinal: its text part). */
|
|
65
|
+
| { readonly type: "delta"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly ordinal?: number; readonly text: string }
|
|
50
66
|
/** Reasoning text streamed in a root or subagent session. */
|
|
51
67
|
| { readonly type: "reasoning"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly delta: string }
|
|
52
68
|
| { readonly type: "conversation"; readonly sessionID: string; readonly on: boolean }
|
|
53
|
-
/**
|
|
54
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Assistant text of a root session in conversation mode, to be spoken. Root text is spoken while it streams:
|
|
71
|
+
* one say per completed sentence, `seq` counting the sentences of the message's text part (`ordinal`) from 0.
|
|
72
|
+
* A subagent's final result is one say for the whole text part (no `seq`).
|
|
73
|
+
*/
|
|
74
|
+
| { readonly type: "say"; readonly sessionID: string; readonly messageID: string; readonly ordinal: number; readonly seq?: number; readonly text: string }
|
|
55
75
|
/** An utterance sent from the phone reached the agent. */
|
|
56
76
|
| { readonly type: "heard"; readonly sessionID: string; readonly inboxID: string }
|
|
57
77
|
/** An activity item was created or changed state (sessionID: where it happened; rootID: its root). */
|
|
@@ -71,6 +91,8 @@ export interface ActivityItem {
|
|
|
71
91
|
readonly summary?: string
|
|
72
92
|
/** The tool was started in the background (e.g. a background subagent). */
|
|
73
93
|
readonly background?: true
|
|
94
|
+
/** A subagent tool: the child session it runs (once the tool reported it). */
|
|
95
|
+
readonly childSessionID?: string
|
|
74
96
|
readonly state: ActivityState
|
|
75
97
|
readonly error?: string
|
|
76
98
|
/** ms epoch of creation, then of the last state change. */
|
|
@@ -86,26 +108,73 @@ const ACTIVITY_ROOTS = 200
|
|
|
86
108
|
export const PRESENCE_WINDOW_MS = 45_000
|
|
87
109
|
/** Child completion text is useful only while its matching run completion is still nearby. */
|
|
88
110
|
export const WORKER_SPEECH_TTL_MS = 5 * 60_000
|
|
111
|
+
/** A worker result is prefixed with its task's title while another worker runs or was announced within this window. */
|
|
112
|
+
export const ANNOUNCE_GROUP_MS = 60_000
|
|
113
|
+
/** Root speech within this window counts as heard: a worker result it already contained is not read again. */
|
|
114
|
+
export const HEARD_WINDOW_MS = 2 * 60_000
|
|
115
|
+
/**
|
|
116
|
+
* How long a session whose turn ended by an inferred interruption (session.execution.interrupted `inferred`: the
|
|
117
|
+
* harness reported none, the spoke found it later) waits before it counts as idle (Registry.idle).
|
|
118
|
+
*/
|
|
119
|
+
export const INFERRED_IDLE_GRACE_MS = 5_000
|
|
120
|
+
/** Another process forwarding the same worker failure within this window is not announced again. */
|
|
121
|
+
const FAILURE_DEDUPE_MS = 30_000
|
|
122
|
+
const ANNOUNCE_REASON_MAX = 150
|
|
123
|
+
|
|
124
|
+
/** Lowercase words only, for "was this already said" checks. */
|
|
125
|
+
const spokenForm = (text: string) =>
|
|
126
|
+
text
|
|
127
|
+
.toLowerCase()
|
|
128
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
129
|
+
.trim()
|
|
89
130
|
|
|
90
131
|
/** Push-worthy transitions of a root session. Kept off the SSE stream. */
|
|
91
132
|
export interface Alert {
|
|
92
|
-
|
|
133
|
+
/**
|
|
134
|
+
* The notification kind (docs/notifications.md); activity: a step of interest for activity updates (`step`), the
|
|
135
|
+
* push layer collects and throttles them.
|
|
136
|
+
*/
|
|
137
|
+
readonly kind: NotificationKind | "activity"
|
|
93
138
|
/** Root session of the tree the event happened in. */
|
|
94
139
|
readonly sessionID: string
|
|
95
140
|
/** Root session title. */
|
|
96
141
|
readonly title: string
|
|
97
|
-
|
|
98
|
-
/** say: the spoken text. */
|
|
142
|
+
/** The body: the spoken text (say), a reason (failed, stopped, retry), a command (scripts), the parent session. */
|
|
99
143
|
readonly text?: string
|
|
100
|
-
/**
|
|
144
|
+
/** permission: the request ID. */
|
|
101
145
|
readonly requestID?: string
|
|
146
|
+
/** What it is about, for the title: a subagent's title, a job's outcome ("exited 1"), a duration ("45s"). */
|
|
147
|
+
readonly subject?: string
|
|
148
|
+
/** Identity of the thing that ended (subagent session, job, tool call): one notification each, whatever the cooldown. */
|
|
149
|
+
readonly key?: string
|
|
102
150
|
/** Tenant (user ID) that owns the session; push goes to this user's devices only. */
|
|
103
151
|
readonly owner?: string
|
|
152
|
+
readonly step?: ActivityStep
|
|
104
153
|
}
|
|
105
154
|
|
|
155
|
+
/** Notifications that reach a session even while some device shows it. */
|
|
156
|
+
const ALWAYS_ALERT: ReadonlySet<NotificationKind> = new Set(["handoff", "question", "permission", "say"])
|
|
157
|
+
|
|
158
|
+
/** Interruptions worth a "stopped" notification (a user stop or a newer turn superseding it are not). */
|
|
159
|
+
const STOP_REASONS: Readonly<Record<string, string>> = { shutdown: "OpenCode shut down", inactivity: "Stopped after inactivity" }
|
|
160
|
+
|
|
106
161
|
/** Machine ID → tenant (owning user ID). Every machine belongs to exactly one tenant. */
|
|
107
162
|
export type TenantOf = (machineID: string) => string | undefined
|
|
108
163
|
|
|
164
|
+
/** A pending permission: where it was asked, every connection that forwarded it, what it asks. */
|
|
165
|
+
interface PendingPermission {
|
|
166
|
+
connIDs: string[]
|
|
167
|
+
readonly sessionID: string
|
|
168
|
+
readonly tenant?: string
|
|
169
|
+
readonly request?: PermissionRequest
|
|
170
|
+
recommendation?: BlockerRecommendation
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Characters of a recommendation's author and reason on a blocker. */
|
|
174
|
+
const RECOMMENDATION_BY_MAX = 200
|
|
175
|
+
const RECOMMENDATION_WHY_MAX = 1_000
|
|
176
|
+
const DECISION_ID = /^dec_[A-Za-z0-9]{1,64}$/
|
|
177
|
+
|
|
109
178
|
/** A pending permission or question somewhere in a session's tree, with where it was asked. */
|
|
110
179
|
export interface TreeBlocker extends Blocker {
|
|
111
180
|
readonly sessionID: string
|
|
@@ -165,8 +234,15 @@ export interface CatalogProcess {
|
|
|
165
234
|
readonly directory: string
|
|
166
235
|
readonly pid: number
|
|
167
236
|
readonly mode: SpokeMode
|
|
237
|
+
/** OpenCode's version as the hello sent it (kept for older consoles; see harnessVersion). */
|
|
168
238
|
readonly opencodeVersion: string
|
|
169
239
|
readonly pluginVersion: string
|
|
240
|
+
/** Harness id ("opencode" for hellos that predate harness), its version and the declared evidence level. */
|
|
241
|
+
readonly harness: string
|
|
242
|
+
readonly harnessVersion: string
|
|
243
|
+
readonly evidence: EvidenceLevel | "unknown"
|
|
244
|
+
/** What the process serves (spokeFeatures: core ops and optional features), sorted; consoles gate controls on it. */
|
|
245
|
+
readonly features: readonly string[]
|
|
170
246
|
readonly connected: number
|
|
171
247
|
readonly lastSeen: number
|
|
172
248
|
readonly sessions: CatalogSession[]
|
|
@@ -188,6 +264,8 @@ interface Conn {
|
|
|
188
264
|
readonly id: string
|
|
189
265
|
readonly machine: MachineRef
|
|
190
266
|
readonly hello: Hello
|
|
267
|
+
/** What the process serves: announced features ∪ the implied v1 set for an old OpenCode hello. */
|
|
268
|
+
readonly features: ReadonlySet<string>
|
|
191
269
|
readonly connected: number
|
|
192
270
|
readonly seq: number
|
|
193
271
|
lastSeen: number
|
|
@@ -196,16 +274,88 @@ interface Conn {
|
|
|
196
274
|
sessionsKey: string
|
|
197
275
|
/** sessionID → activity sequence number of the last event this process sent for it. */
|
|
198
276
|
activity: Map<string, number>
|
|
277
|
+
/**
|
|
278
|
+
* The launch this connection is bound to (it authenticated with that launch's token, phase 5). Such a
|
|
279
|
+
* connection is never the machine's service nor an exposure target.
|
|
280
|
+
*/
|
|
281
|
+
readonly launchID?: string
|
|
282
|
+
/** A connection another pod holds (from its slice): its tenant as that pod saw it. */
|
|
283
|
+
readonly remote?: { readonly pod: string; readonly tenant: string }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A connection in a RegistrySlice. */
|
|
287
|
+
export interface SliceConn {
|
|
288
|
+
readonly id: string
|
|
289
|
+
readonly machine: MachineRef
|
|
290
|
+
readonly hello: Hello
|
|
291
|
+
readonly connected: number
|
|
292
|
+
readonly seq: number
|
|
293
|
+
readonly lastSeen: number
|
|
294
|
+
readonly sessions: SessionInfo[]
|
|
295
|
+
/** sessionID → recency (comparable across pods: clock-based). */
|
|
296
|
+
readonly activity: Array<[string, number]>
|
|
297
|
+
readonly launchID?: string
|
|
298
|
+
/** The machine's terminal context, when this connection sent the latest one. */
|
|
299
|
+
readonly terminals?: MachineTerminals
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A root session's status tree (live: run starts and recommendations included) and activity feed. */
|
|
303
|
+
export interface SliceBoard {
|
|
304
|
+
readonly rootID: string
|
|
305
|
+
readonly sessions: LiveStatus[]
|
|
306
|
+
readonly activity: ActivityItem[]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export interface SlicePermission {
|
|
310
|
+
readonly requestID: string
|
|
311
|
+
readonly sessionID: string
|
|
312
|
+
readonly connIDs: string[]
|
|
313
|
+
readonly request?: PermissionRequest
|
|
314
|
+
readonly recommendation?: BlockerRecommendation
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface SliceForm {
|
|
318
|
+
readonly formID: string
|
|
319
|
+
readonly sessionID: string
|
|
320
|
+
readonly connIDs: string[]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** One tenant's state on one pod, as other pods mirror it (JSON-compatible). */
|
|
324
|
+
export interface RegistrySlice {
|
|
325
|
+
readonly conns: SliceConn[]
|
|
326
|
+
readonly boards: SliceBoard[]
|
|
327
|
+
readonly permissions: SlicePermission[]
|
|
328
|
+
readonly forms: SliceForm[]
|
|
329
|
+
readonly presence: Array<{ readonly deviceID: string; readonly sessionID: string; readonly at: number }>
|
|
330
|
+
/** Sessions with a hub turn running on that pod. */
|
|
331
|
+
readonly busy: string[]
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** How a connection was authenticated, beyond its machine. */
|
|
335
|
+
export interface ConnectOptions {
|
|
336
|
+
/** The launch whose launch-bound token the connection presented (checked by /spoke/ws). */
|
|
337
|
+
readonly launchID?: string
|
|
199
338
|
}
|
|
200
339
|
|
|
201
340
|
/** Streamed text of one session waiting for its next send window. */
|
|
202
341
|
interface Stream {
|
|
203
342
|
last: number
|
|
204
343
|
timer?: unknown
|
|
205
|
-
pending: Array<{ kind: "delta" | "reasoning"; rootID: string; messageID?: string; text: string }>
|
|
344
|
+
pending: Array<{ kind: "delta" | "reasoning"; rootID: string; messageID?: string; ordinal?: number; text: string }>
|
|
206
345
|
}
|
|
207
346
|
|
|
208
347
|
/** Latest assistant message of a child run, paired with its successful completion for direct speech. */
|
|
348
|
+
/** A root text part being spoken while it streams (deltas from the first process that streamed it). */
|
|
349
|
+
interface RootSpeech {
|
|
350
|
+
readonly sessionID: string
|
|
351
|
+
readonly connID: string
|
|
352
|
+
readonly stream: SentenceStream
|
|
353
|
+
seq: number
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Root text parts being spoken at once (more are dropped oldest first). */
|
|
357
|
+
const ROOT_SPEECH_LIMIT = 100
|
|
358
|
+
|
|
209
359
|
interface WorkerSpeech {
|
|
210
360
|
readonly rootID: string
|
|
211
361
|
succeeded: boolean
|
|
@@ -240,11 +390,19 @@ export class Registry {
|
|
|
240
390
|
* permission requestID → its session and every connection that forwarded it, first first. Usually one
|
|
241
391
|
* process holds it, but several may report the session; a reply tries them in order.
|
|
242
392
|
*/
|
|
243
|
-
private readonly permissions = new Map<string,
|
|
393
|
+
private readonly permissions = new Map<string, PendingPermission>()
|
|
394
|
+
/** When a session's latest turn ended by an inferred interruption (see INFERRED_IDLE_GRACE_MS). */
|
|
395
|
+
private readonly inferredEnds = new Map<string, number>()
|
|
396
|
+
/** Told when a pending permission goes away (replied, dropped, its processes gone). */
|
|
397
|
+
private readonly permissionClosedListeners = new Set<(requestID: string, sessionID: string, tenant: string | undefined) => void>()
|
|
244
398
|
/** Sessions with a hub turn in flight, with how to end the waiting request early (stop/interrupt). */
|
|
245
399
|
private readonly active = new Map<string, { readonly cancel?: () => void }>()
|
|
246
400
|
private readonly listeners = new Set<(message: RegistryMessage, tenant?: string) => void>()
|
|
247
401
|
private readonly alertListeners = new Set<(alert: Alert) => void>()
|
|
402
|
+
/** Called after every sessions frame (unchanged snapshots too), e.g. to push session tokens. */
|
|
403
|
+
private readonly sessionsListeners = new Set<(connID: string) => void>()
|
|
404
|
+
/** Called when the user's own prompt reached a session's inbox (typed or spoken in the harness). */
|
|
405
|
+
private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
|
|
248
406
|
/** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
|
|
249
407
|
private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
|
|
250
408
|
/** Form IDs already alerted (forms may be forwarded by several processes). */
|
|
@@ -253,8 +411,17 @@ export class Registry {
|
|
|
253
411
|
private readonly conversations = new Map<string, string | undefined>()
|
|
254
412
|
/** `messageID ordinal` of texts already sent as say events (several processes forward the same text). */
|
|
255
413
|
private readonly spoken = new Map<string, true>()
|
|
414
|
+
/** `messageID ordinal` → a root text part being spoken sentence by sentence as it streams. */
|
|
415
|
+
private readonly rootSpeech = new Map<string, RootSpeech>()
|
|
256
416
|
/** Child session → latest text and completion state, while its root is in conversation mode. */
|
|
257
417
|
private readonly workerSpeech = new Map<string, WorkerSpeech>()
|
|
418
|
+
/** Root → what it said lately (spokenForm), so a worker result the voice agent already relayed is skipped. */
|
|
419
|
+
private readonly rootHeard = new Map<string, Array<{ text: string; at: number }>>()
|
|
420
|
+
/** Root → when a worker announcement of its tree was last spoken. */
|
|
421
|
+
private readonly lastAnnounced = new Map<string, number>()
|
|
422
|
+
/** Worker → when its failure was announced; permission/form ID → announced. */
|
|
423
|
+
private readonly announcedFailures = new Map<string, number>()
|
|
424
|
+
private readonly announcedBlockers = new Map<string, true>()
|
|
258
425
|
/** Utterance inboxID → session, until OpenCode reports it delivered. */
|
|
259
426
|
private readonly utterances = new Map<string, string>()
|
|
260
427
|
/** Deliveries not (yet) matched to an utterance: the steer result can arrive after the event. */
|
|
@@ -270,13 +437,27 @@ export class Registry {
|
|
|
270
437
|
/** Machine → its latest terminals frame and the connection that sent it. */
|
|
271
438
|
private readonly machineTerminals = new Map<string, { connID: string; terminals: MachineTerminals; key: string }>()
|
|
272
439
|
private seq = 0
|
|
440
|
+
private readonly doneWatch: DoneWatch
|
|
441
|
+
/** Told when this pod's state of a tenant changed (undefined: any tenant). */
|
|
442
|
+
private readonly localChangeListeners = new Set<(tenant: string | undefined) => void>()
|
|
443
|
+
/** Other pods' slices: pod → tenant → slice; the maps below index them (see reindex). */
|
|
444
|
+
private readonly remoteSlices = new Map<string, Map<string, RegistrySlice>>()
|
|
445
|
+
private remoteConns = new Map<string, Conn>()
|
|
446
|
+
private remoteBoards = new Map<string, SliceBoard>()
|
|
447
|
+
private remotePermissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
|
|
448
|
+
private remoteForms = new Map<string, { sessionID: string; connIDs: string[] }>()
|
|
449
|
+
private remotePresence: Array<{ sessionID: string; at: number }> = []
|
|
450
|
+
private remoteBusy = new Set<string>()
|
|
451
|
+
private remoteTerminals = new Map<string, MachineTerminals>()
|
|
273
452
|
|
|
274
453
|
constructor(
|
|
275
454
|
private readonly now: () => number = Date.now,
|
|
276
455
|
private readonly timers: Timers = realTimers,
|
|
277
456
|
/** Single tenant unless given (pairing mode, tests). */
|
|
278
457
|
private readonly tenantOf: TenantOf = () => "",
|
|
279
|
-
) {
|
|
458
|
+
) {
|
|
459
|
+
this.doneWatch = new DoneWatch(timers, (rootID) => this.settledTree(rootID), (rootID) => this.alert("done", rootID))
|
|
460
|
+
}
|
|
280
461
|
|
|
281
462
|
/**
|
|
282
463
|
* Listens to registry messages. `tenant` is the user that owns the message's session (undefined for
|
|
@@ -306,13 +487,29 @@ export class Registry {
|
|
|
306
487
|
// claim, read or inject into another user's session by reusing its ID.
|
|
307
488
|
|
|
308
489
|
private tenant(conn: Conn): string {
|
|
309
|
-
return this.tenantOf(conn.machine.id) ?? `?${conn.machine.id}`
|
|
490
|
+
return conn.remote ? conn.remote.tenant : (this.tenantOf(conn.machine.id) ?? `?${conn.machine.id}`)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** A connection of this pod or (remote) of another one. */
|
|
494
|
+
private conn(connID: string): Conn | undefined {
|
|
495
|
+
return this.conns.get(connID) ?? this.remoteConns.get(connID)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Every connection: this pod's, then other pods'. */
|
|
499
|
+
private all(): Conn[] {
|
|
500
|
+
return this.remoteConns.size ? [...this.conns.values(), ...this.remoteConns.values()] : [...this.conns.values()]
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** A sequence number that also orders across pods (clock-based, strictly increasing here). */
|
|
504
|
+
private tick(): number {
|
|
505
|
+
this.seq = Math.max(this.seq + 1, this.now())
|
|
506
|
+
return this.seq
|
|
310
507
|
}
|
|
311
508
|
|
|
312
509
|
/** Tenant of a reported session, or of the status board that contains it; undefined when unknown. */
|
|
313
510
|
sessionTenant(sessionID: string): string | undefined {
|
|
314
511
|
let first: Conn | undefined
|
|
315
|
-
for (const conn of this.
|
|
512
|
+
for (const conn of this.all()) if (conn.sessions.has(sessionID) && (!first || conn.seq < first.seq)) first = conn
|
|
316
513
|
if (first) return this.tenant(first)
|
|
317
514
|
const rootID = this.rootOf(sessionID)
|
|
318
515
|
return rootID && rootID !== sessionID ? this.sessionTenant(rootID) : undefined
|
|
@@ -336,23 +533,58 @@ export class Registry {
|
|
|
336
533
|
|
|
337
534
|
/** Whether the connection's process belongs to the tenant. */
|
|
338
535
|
connTenant(connID: string): string | undefined {
|
|
339
|
-
const conn = this.
|
|
536
|
+
const conn = this.conn(connID)
|
|
340
537
|
return conn && this.tenant(conn)
|
|
341
538
|
}
|
|
342
539
|
|
|
343
|
-
|
|
540
|
+
/** IDs of the sessions the connection reports for its own tenant (subagents included); empty when unknown. */
|
|
541
|
+
reportedSessions(connID: string): string[] {
|
|
542
|
+
const conn = this.conn(connID)
|
|
543
|
+
return conn ? this.visible(conn).map((s) => s.id) : []
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** The machine of a connected process. */
|
|
547
|
+
connMachine(connID: string): string | undefined {
|
|
548
|
+
return this.conn(connID)?.machine.id
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Whether a connected process of the machine reports the session for the machine's tenant. */
|
|
552
|
+
machineReports(machineID: string, sessionID: string): boolean {
|
|
553
|
+
for (const conn of this.all()) if (conn.machine.id === machineID && this.reports(conn, sessionID)) return true
|
|
554
|
+
return false
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Listens to every sessions frame (after it is applied, unchanged snapshots included). */
|
|
558
|
+
onSessions(listener: (connID: string) => void): () => void {
|
|
559
|
+
this.sessionsListeners.add(listener)
|
|
560
|
+
return () => this.sessionsListeners.delete(listener)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Listens to human turns the spokes report: a prompt the user put into a session's inbox
|
|
565
|
+
* (session.inbox.delivered with origin "user"); `tenant` owns the session.
|
|
566
|
+
*/
|
|
567
|
+
onHumanTurn(listener: (sessionID: string, tenant?: string) => void): () => void {
|
|
568
|
+
this.humanTurnListeners.add(listener)
|
|
569
|
+
return () => this.humanTurnListeners.delete(listener)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
connect(connID: string, machine: MachineRef, hello: Hello, options: ConnectOptions = {}): void {
|
|
344
573
|
const t = this.now()
|
|
345
574
|
this.conns.set(connID, {
|
|
575
|
+
...(options.launchID !== undefined ? { launchID: options.launchID } : {}),
|
|
346
576
|
id: connID,
|
|
347
577
|
machine,
|
|
348
578
|
hello,
|
|
579
|
+
features: spokeFeatures(hello),
|
|
349
580
|
connected: t,
|
|
350
|
-
seq:
|
|
581
|
+
seq: this.tick(),
|
|
351
582
|
lastSeen: t,
|
|
352
583
|
sessions: new Map(),
|
|
353
584
|
sessionsKey: "[]",
|
|
354
585
|
activity: new Map(),
|
|
355
586
|
})
|
|
587
|
+
this.changed(this.tenant(this.conns.get(connID)!))
|
|
356
588
|
this.emit({ type: "catalog" })
|
|
357
589
|
}
|
|
358
590
|
|
|
@@ -367,6 +599,7 @@ export class Registry {
|
|
|
367
599
|
if (origin.connIDs.length) continue
|
|
368
600
|
this.permissions.delete(requestID)
|
|
369
601
|
this.observeBoards({ type: "permission.replied", data: { sessionID: origin.sessionID, requestID } })
|
|
602
|
+
this.permissionClosed(requestID, origin)
|
|
370
603
|
}
|
|
371
604
|
for (const [formID, origin] of this.forms) {
|
|
372
605
|
if (!origin.connIDs.includes(connID)) continue
|
|
@@ -376,6 +609,7 @@ export class Registry {
|
|
|
376
609
|
this.observeBoards({ type: "form.cancelled", data: { sessionID: origin.sessionID, id: formID } })
|
|
377
610
|
}
|
|
378
611
|
this.syncBoards()
|
|
612
|
+
this.changed(this.tenant(conn))
|
|
379
613
|
this.emit({ type: "catalog" })
|
|
380
614
|
}
|
|
381
615
|
|
|
@@ -390,11 +624,14 @@ export class Registry {
|
|
|
390
624
|
conn.lastSeen = this.now()
|
|
391
625
|
// Spokes re-send their snapshot periodically; only changes reach the phones.
|
|
392
626
|
const key = JSON.stringify(sessions)
|
|
393
|
-
if (key
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
627
|
+
if (key !== conn.sessionsKey) {
|
|
628
|
+
conn.sessionsKey = key
|
|
629
|
+
conn.sessions = new Map(sessions.map((s) => [s.id, s]))
|
|
630
|
+
this.syncBoards()
|
|
631
|
+
this.changed(this.tenant(conn))
|
|
632
|
+
this.emit({ type: "catalog" })
|
|
633
|
+
}
|
|
634
|
+
for (const listener of [...this.sessionsListeners]) listener(connID)
|
|
398
635
|
}
|
|
399
636
|
|
|
400
637
|
/** Records a machine's terminal context (sent by one process per machine); catalog on change. */
|
|
@@ -406,12 +643,14 @@ export class Registry {
|
|
|
406
643
|
const key = JSON.stringify(terminals)
|
|
407
644
|
const previous = this.machineTerminals.get(conn.machine.id)
|
|
408
645
|
this.machineTerminals.set(conn.machine.id, { connID, terminals, key })
|
|
409
|
-
if (previous?.key
|
|
646
|
+
if (previous?.key === key) return
|
|
647
|
+
this.changed(this.tenant(conn))
|
|
648
|
+
this.emit({ type: "catalog" })
|
|
410
649
|
}
|
|
411
650
|
|
|
412
651
|
/** The machine's terminal context, while the process that sent it is connected. */
|
|
413
652
|
terminals(machineID: string): MachineTerminals | undefined {
|
|
414
|
-
return this.machineTerminals.get(machineID)?.terminals
|
|
653
|
+
return this.machineTerminals.get(machineID)?.terminals ?? this.remoteTerminals.get(machineID)
|
|
415
654
|
}
|
|
416
655
|
|
|
417
656
|
observe(connID: string, event: ForwardedEvent): void {
|
|
@@ -427,8 +666,14 @@ export class Registry {
|
|
|
427
666
|
const owner = this.sessionTenant(id)
|
|
428
667
|
if (owner !== undefined && owner !== tenant) return
|
|
429
668
|
}
|
|
430
|
-
if (sessionID) conn.activity.set(sessionID,
|
|
669
|
+
if (sessionID) conn.activity.set(sessionID, this.tick())
|
|
670
|
+
if (sessionID && event.type.startsWith("session.execution.")) {
|
|
671
|
+
if (event.type === "session.execution.interrupted" && data.inferred === true) remember(this.inferredEnds, sessionID, this.now())
|
|
672
|
+
else this.inferredEnds.delete(sessionID)
|
|
673
|
+
}
|
|
431
674
|
const streamed = event.type === "session.text.delta" || event.type === "session.reasoning.delta"
|
|
675
|
+
// Streamed text changes nothing other pods mirror (its recency goes out with the next change).
|
|
676
|
+
if (!streamed) this.changed(tenant)
|
|
432
677
|
// Anything else in a session (text ended → say, run ended → status) goes out after its streamed text.
|
|
433
678
|
if (sessionID && !streamed) this.flushStream(this.streams.get(sessionID), sessionID)
|
|
434
679
|
|
|
@@ -439,8 +684,9 @@ export class Registry {
|
|
|
439
684
|
if (!known.connIDs.includes(connID)) known.connIDs.push(connID)
|
|
440
685
|
return
|
|
441
686
|
}
|
|
442
|
-
|
|
443
|
-
this.
|
|
687
|
+
const request = permissionRequest(data)
|
|
688
|
+
this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}) })
|
|
689
|
+
this.alert("permission", sessionID, { requestID: data.id })
|
|
444
690
|
}
|
|
445
691
|
if (event.type === "form.created" && sessionID) {
|
|
446
692
|
const formID = (data.form as Record<string, unknown> | undefined)?.id
|
|
@@ -452,37 +698,41 @@ export class Registry {
|
|
|
452
698
|
if (typeof formID === "string" && !this.alertedForms.has(formID)) {
|
|
453
699
|
this.alertedForms.add(formID)
|
|
454
700
|
if (this.alertedForms.size > 1000) this.alertedForms.delete(this.alertedForms.values().next().value!)
|
|
455
|
-
this.alert("
|
|
701
|
+
this.alert("question", sessionID)
|
|
456
702
|
}
|
|
457
703
|
}
|
|
458
|
-
|
|
459
|
-
(event.type === "session.execution.succeeded" || event.type === "session.execution.failed") &&
|
|
460
|
-
sessionID &&
|
|
461
|
-
this.boards.has(sessionID) &&
|
|
462
|
-
!this.active.has(sessionID) &&
|
|
463
|
-
!(this.conversations.has(sessionID) && this.watched(sessionID))
|
|
464
|
-
) {
|
|
465
|
-
this.alert("finished", sessionID, event.type.endsWith("succeeded") ? "succeeded" : "failed")
|
|
466
|
-
}
|
|
704
|
+
let closedPermission: [string, PendingPermission] | undefined
|
|
467
705
|
if (event.type === "permission.replied" && typeof data.requestID === "string") {
|
|
468
|
-
this.permissions.
|
|
706
|
+
const origin = this.permissions.get(data.requestID)
|
|
707
|
+
if (origin && this.permissions.delete(data.requestID)) closedPermission = [data.requestID, origin]
|
|
469
708
|
}
|
|
470
709
|
if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string") {
|
|
471
710
|
this.forms.delete(data.id)
|
|
472
711
|
}
|
|
473
712
|
|
|
474
713
|
if (sessionID) this.trackRun(event.type, sessionID)
|
|
714
|
+
const before = sessionID ? this.status(sessionID)?.find((s) => s.id === sessionID) : undefined
|
|
475
715
|
this.observeBoards(event, tenant)
|
|
716
|
+
if (sessionID) this.activitySteps(event.type, sessionID, data, before)
|
|
717
|
+
if (sessionID) this.notifications(event.type, sessionID, data, before)
|
|
476
718
|
if (sessionID) this.observeActivity(event, sessionID, data)
|
|
477
719
|
if (sessionID) this.observeWorkerSpeech(event.type, sessionID, data)
|
|
720
|
+
if (sessionID) this.observeWorkerBlockers(event.type, sessionID, data)
|
|
478
721
|
|
|
479
722
|
if (streamed && sessionID) {
|
|
480
723
|
const rootID = this.rootOf(sessionID)
|
|
481
724
|
if (rootID) this.stream(sessionID, rootID, event.type === "session.text.delta" ? "delta" : "reasoning", data)
|
|
482
725
|
}
|
|
726
|
+
if (event.type === "session.text.delta" && sessionID) this.speakDelta(connID, sessionID, data)
|
|
483
727
|
if (event.type === "session.text.ended" && sessionID) this.say(sessionID, data)
|
|
728
|
+
if ((event.type === "session.execution.interrupted" || event.type === "session.execution.failed") && sessionID) this.dropRootSpeech(sessionID)
|
|
484
729
|
if (event.type === "session.inbox.delivered" && typeof data.inboxID === "string") this.delivered(data.inboxID)
|
|
730
|
+
if (event.type === "session.inbox.delivered" && data.origin === "user" && sessionID) {
|
|
731
|
+
const tenant = this.sessionTenant(sessionID)
|
|
732
|
+
for (const listener of [...this.humanTurnListeners]) listener(sessionID, tenant)
|
|
733
|
+
}
|
|
485
734
|
if (event.type === "session.deleted" && sessionID) this.setConversation(sessionID, false)
|
|
735
|
+
if (closedPermission) this.permissionClosed(...closedPermission)
|
|
486
736
|
}
|
|
487
737
|
|
|
488
738
|
/** Records when a session's run started (kept by the first process to report it while it runs). */
|
|
@@ -500,11 +750,12 @@ export class Registry {
|
|
|
500
750
|
const text = typeof data.delta === "string" ? data.delta : ""
|
|
501
751
|
if (!text) return
|
|
502
752
|
const messageID = typeof data.assistantMessageID === "string" ? data.assistantMessageID : undefined
|
|
753
|
+
const ordinal = kind === "delta" && Number.isInteger(data.ordinal) && (data.ordinal as number) >= 0 ? (data.ordinal as number) : undefined
|
|
503
754
|
let stream = this.streams.get(sessionID)
|
|
504
755
|
if (!stream) remember(this.streams, sessionID, (stream = { last: -Infinity, pending: [] }))
|
|
505
756
|
const tail = stream.pending.at(-1)
|
|
506
|
-
if (tail && tail.kind === kind && tail.messageID === messageID && tail.rootID === rootID) tail.text += text
|
|
507
|
-
else stream.pending.push({ kind, rootID, messageID, text })
|
|
757
|
+
if (tail && tail.kind === kind && tail.messageID === messageID && tail.ordinal === ordinal && tail.rootID === rootID) tail.text += text
|
|
758
|
+
else stream.pending.push({ kind, rootID, messageID, ...(ordinal === undefined ? {} : { ordinal }), text })
|
|
508
759
|
if (stream.timer !== undefined) return
|
|
509
760
|
const wait = stream.last + STREAM_INTERVAL_MS - this.now()
|
|
510
761
|
if (wait <= 0) return this.flushStream(stream, sessionID)
|
|
@@ -523,9 +774,9 @@ export class Registry {
|
|
|
523
774
|
const chunks = stream.pending
|
|
524
775
|
stream.pending = []
|
|
525
776
|
stream.last = this.now()
|
|
526
|
-
for (const { kind, rootID, messageID, text } of chunks) {
|
|
777
|
+
for (const { kind, rootID, messageID, ordinal, text } of chunks) {
|
|
527
778
|
const message = { sessionID, rootID, ...(messageID ? { messageID } : {}) }
|
|
528
|
-
this.emit(kind === "delta" ? { type: "delta", ...message, text } : { type: "reasoning", ...message, delta: text })
|
|
779
|
+
this.emit(kind === "delta" ? { type: "delta", ...message, ...(ordinal === undefined ? {} : { ordinal }), text } : { type: "reasoning", ...message, delta: text })
|
|
529
780
|
}
|
|
530
781
|
}
|
|
531
782
|
|
|
@@ -558,12 +809,54 @@ export class Registry {
|
|
|
558
809
|
this.emit({ type: "heard", sessionID, inboxID })
|
|
559
810
|
}
|
|
560
811
|
|
|
812
|
+
/** Root text streaming in conversation mode: each sentence is spoken as soon as it is complete. */
|
|
813
|
+
private speakDelta(connID: string, sessionID: string, data: Record<string, unknown>): void {
|
|
814
|
+
if (this.rootOf(sessionID) !== sessionID || !this.conversations.has(sessionID) || !this.boards.has(sessionID)) return
|
|
815
|
+
const { assistantMessageID: messageID, ordinal, delta } = data
|
|
816
|
+
if (typeof messageID !== "string" || typeof ordinal !== "number" || typeof delta !== "string") return
|
|
817
|
+
const key = `${messageID} ${ordinal}`
|
|
818
|
+
if (this.spoken.has(key)) return
|
|
819
|
+
let state = this.rootSpeech.get(key)
|
|
820
|
+
if (!state) remember(this.rootSpeech, key, (state = { sessionID, connID, stream: new SentenceStream(), seq: 0 }), ROOT_SPEECH_LIMIT)
|
|
821
|
+
// Other processes forward the same deltas: one stream per text part.
|
|
822
|
+
if (state.connID !== connID) return
|
|
823
|
+
const sentences = state.stream.push(delta)
|
|
824
|
+
// The sentence's own text goes out first, so a say never overtakes its bubble.
|
|
825
|
+
if (sentences.length) this.flushStream(this.streams.get(sessionID), sessionID)
|
|
826
|
+
for (const text of sentences) this.emitSentence(state, messageID, ordinal, text)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** A root text part ended: speaks what the deltas did not (the ended text is authoritative), once. */
|
|
561
830
|
private say(sessionID: string, data: Record<string, unknown>): void {
|
|
562
831
|
if (this.rootOf(sessionID) !== sessionID) return
|
|
563
|
-
|
|
832
|
+
const { assistantMessageID: messageID, ordinal, text } = data
|
|
833
|
+
if (!this.conversations.has(sessionID) || !this.boards.has(sessionID)) return
|
|
834
|
+
if (typeof messageID !== "string" || typeof ordinal !== "number" || typeof text !== "string") return
|
|
835
|
+
const key = `${messageID} ${ordinal}`
|
|
836
|
+
if (this.spoken.has(key)) return
|
|
837
|
+
remember(this.spoken, key, true)
|
|
838
|
+
const state = this.rootSpeech.get(key) ?? { sessionID, connID: "", stream: new SentenceStream(), seq: 0 }
|
|
839
|
+
this.rootSpeech.delete(key)
|
|
840
|
+
for (const sentence of state.stream.end(text)) this.emitSentence(state, messageID, ordinal, sentence)
|
|
841
|
+
// Nobody is listening on a phone: push the whole reply so it reaches a locked screen.
|
|
842
|
+
if (text.trim() && !this.watched(sessionID)) this.alert("say", sessionID, { text })
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private emitSentence(state: RootSpeech, messageID: string, ordinal: number, text: string): void {
|
|
846
|
+
this.heardFrom(state.sessionID, text)
|
|
847
|
+
this.emit({ type: "say", sessionID: state.sessionID, messageID, ordinal, seq: state.seq++, text })
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** The run stopped or conversation mode ended: the rest of its text parts is not spoken. */
|
|
851
|
+
private dropRootSpeech(sessionID: string): void {
|
|
852
|
+
for (const [key, state] of this.rootSpeech) {
|
|
853
|
+
if (state.sessionID !== sessionID) continue
|
|
854
|
+
this.rootSpeech.delete(key)
|
|
855
|
+
remember(this.spoken, key, true)
|
|
856
|
+
}
|
|
564
857
|
}
|
|
565
858
|
|
|
566
|
-
/** Emits one
|
|
859
|
+
/** Emits one subagent result part through its root, deduped across processes. */
|
|
567
860
|
private emitSay(sessionID: string, data: Record<string, unknown>): void {
|
|
568
861
|
const { assistantMessageID: messageID, ordinal, text } = data
|
|
569
862
|
if (!this.conversations.has(sessionID) || !this.boards.has(sessionID)) return
|
|
@@ -573,7 +866,74 @@ export class Registry {
|
|
|
573
866
|
remember(this.spoken, key, true)
|
|
574
867
|
this.emit({ type: "say", sessionID, messageID, ordinal, text })
|
|
575
868
|
// Nobody is listening on a phone: push the reply so it reaches a locked screen.
|
|
576
|
-
if (!this.watched(sessionID)) this.alert("say", sessionID,
|
|
869
|
+
if (!this.watched(sessionID)) this.alert("say", sessionID, { text })
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Records root speech for `alreadyHeard`, keeping the last few minutes. */
|
|
873
|
+
private heardFrom(rootID: string, text: string): void {
|
|
874
|
+
const now = this.now()
|
|
875
|
+
const recent = (this.rootHeard.get(rootID) ?? []).filter((entry) => now - entry.at <= HEARD_WINDOW_MS).slice(-30)
|
|
876
|
+
recent.push({ text: spokenForm(text), at: now })
|
|
877
|
+
remember(this.rootHeard, rootID, recent, ACTIVITY_ROOTS)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** True when the root's recent speech already contained this text (the voice agent relayed the result). */
|
|
881
|
+
private alreadyHeard(rootID: string, text: string): boolean {
|
|
882
|
+
const wanted = spokenForm(text)
|
|
883
|
+
if (wanted.length < 8) return false
|
|
884
|
+
const now = this.now()
|
|
885
|
+
const said = (this.rootHeard.get(rootID) ?? []).filter((entry) => now - entry.at <= HEARD_WINDOW_MS).map((entry) => entry.text)
|
|
886
|
+
return said.join(" ").includes(wanted)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** A worker's task title (its session title on the root's status board). */
|
|
890
|
+
private workerTitle(rootID: string, childID: string): string {
|
|
891
|
+
return this.status(rootID)?.find((s) => s.id === childID)?.title?.trim() || "A worker"
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Several workers are in play: another one runs (or waits) now, or one was announced a moment ago. */
|
|
895
|
+
private severalWorkers(rootID: string, childID: string): boolean {
|
|
896
|
+
const others = (this.status(rootID) ?? []).filter((s) => s.id !== rootID && s.id !== childID)
|
|
897
|
+
if (others.some((s) => s.state === "working" || s.state === "blocked")) return true
|
|
898
|
+
const last = this.lastAnnounced.get(rootID)
|
|
899
|
+
return last !== undefined && this.now() - last <= ANNOUNCE_GROUP_MS
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Speaks a hub-made announcement through the conversation root (pushed when nobody watches, if asked). */
|
|
903
|
+
private announce(rootID: string, messageID: string, text: string, push: boolean): void {
|
|
904
|
+
remember(this.lastAnnounced, rootID, this.now(), ACTIVITY_ROOTS)
|
|
905
|
+
this.emit({ type: "say", sessionID: rootID, messageID, ordinal: 0, text })
|
|
906
|
+
if (push && !this.watched(rootID)) this.alert("say", rootID, { text })
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** The conversation root of a worker session, if it is one. */
|
|
910
|
+
private conversationRootOf(sessionID: string): string | undefined {
|
|
911
|
+
const rootID = this.rootOf(sessionID)
|
|
912
|
+
return rootID && rootID !== sessionID && this.conversations.has(rootID) && this.boards.has(rootID) ? rootID : undefined
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** A worker that fails, needs a permission or asks a question is announced once ("Deploy check failed: …"). */
|
|
916
|
+
private observeWorkerBlockers(type: string, sessionID: string, data: Record<string, unknown>): void {
|
|
917
|
+
if (type !== "session.execution.failed" && type !== "permission.asked" && type !== "form.created") return
|
|
918
|
+
const rootID = this.conversationRootOf(sessionID)
|
|
919
|
+
if (!rootID) return
|
|
920
|
+
const title = this.workerTitle(rootID, sessionID)
|
|
921
|
+
if (type === "session.execution.failed") {
|
|
922
|
+
const at = this.announcedFailures.get(sessionID)
|
|
923
|
+
if (at !== undefined && this.now() - at <= FAILURE_DEDUPE_MS) return
|
|
924
|
+
remember(this.announcedFailures, sessionID, this.now())
|
|
925
|
+
const message = (data.error as { message?: unknown } | undefined)?.message
|
|
926
|
+
const reason = (typeof message === "string" ? message : "").replace(/\s+/g, " ").trim().replace(/[.!]+$/, "").slice(0, ANNOUNCE_REASON_MAX)
|
|
927
|
+
return this.announce(rootID, `announce:failed:${sessionID}`, reason ? `${title} failed: ${reason}.` : `${title} failed.`, true)
|
|
928
|
+
}
|
|
929
|
+
const id = type === "permission.asked" ? data.id : (data.form as { id?: unknown } | undefined)?.id
|
|
930
|
+
if (typeof id !== "string") return
|
|
931
|
+
const key = `${type} ${id}`
|
|
932
|
+
if (this.announcedBlockers.has(key)) return
|
|
933
|
+
remember(this.announcedBlockers, key, true)
|
|
934
|
+
// "blocked" is pushed already; this is for the ears of whoever is in Voice mode.
|
|
935
|
+
if (type === "permission.asked") this.announce(rootID, `announce:permission:${id}`, `${title} needs your permission.`, false)
|
|
936
|
+
else this.announce(rootID, `announce:form:${id}`, `${title} has a question.`, false)
|
|
577
937
|
}
|
|
578
938
|
|
|
579
939
|
/** Pairs a child session's latest assistant message with successful execution and speaks it once. */
|
|
@@ -622,19 +982,31 @@ export class Registry {
|
|
|
622
982
|
const held = state
|
|
623
983
|
state.timer = this.timers.set(() => {
|
|
624
984
|
held.timer = undefined
|
|
625
|
-
if (this.workerSpeech.get(sessionID) === held) this.flushWorkerSpeech(held)
|
|
985
|
+
if (this.workerSpeech.get(sessionID) === held) this.flushWorkerSpeech(sessionID, held)
|
|
626
986
|
}, STREAM_INTERVAL_MS)
|
|
627
987
|
return
|
|
628
988
|
}
|
|
629
|
-
this.flushWorkerSpeech(state)
|
|
989
|
+
this.flushWorkerSpeech(sessionID, state)
|
|
630
990
|
}
|
|
631
991
|
|
|
632
|
-
|
|
992
|
+
/**
|
|
993
|
+
* Speaks a successful worker's result: prefixed with its task's title when several workers are in play, not at
|
|
994
|
+
* all when the voice agent already said it.
|
|
995
|
+
*/
|
|
996
|
+
private flushWorkerSpeech(childID: string, state: WorkerSpeech): void {
|
|
633
997
|
if (!state.succeeded || !state.messageID || state.parts.size === 0 || !this.conversations.has(state.rootID)) return
|
|
634
|
-
|
|
635
|
-
this.emitSay(state.rootID, { assistantMessageID: state.messageID, ordinal, text })
|
|
636
|
-
}
|
|
998
|
+
const parts = [...state.parts].sort(([a], [b]) => a - b).filter(([ordinal]) => !this.spoken.has(`${state.messageID} ${ordinal}`))
|
|
637
999
|
state.parts.clear()
|
|
1000
|
+
if (!parts.length) return
|
|
1001
|
+
if (this.alreadyHeard(state.rootID, parts.map(([, text]) => text).join(" "))) {
|
|
1002
|
+
for (const [ordinal] of parts) remember(this.spoken, `${state.messageID} ${ordinal}`, true)
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
const prefix = this.severalWorkers(state.rootID, childID) ? `${this.workerTitle(state.rootID, childID)} finished: ` : ""
|
|
1006
|
+
parts.forEach(([ordinal, text], index) => {
|
|
1007
|
+
this.emitSay(state.rootID, { assistantMessageID: state.messageID, ordinal, text: index === 0 ? prefix + text : text })
|
|
1008
|
+
})
|
|
1009
|
+
remember(this.lastAnnounced, state.rootID, this.now(), ACTIVITY_ROOTS)
|
|
638
1010
|
}
|
|
639
1011
|
|
|
640
1012
|
private clearWorkerSpeech(sessionID: string): void {
|
|
@@ -647,14 +1019,15 @@ export class Registry {
|
|
|
647
1019
|
setPresence(deviceID: string, sessionID: string, visible: boolean): void {
|
|
648
1020
|
const key = `${deviceID} ${sessionID}`
|
|
649
1021
|
if (visible) remember(this.presence, key, { sessionID, at: this.now() })
|
|
650
|
-
else this.presence.delete(key)
|
|
1022
|
+
else if (!this.presence.delete(key)) return
|
|
1023
|
+
this.changed(this.sessionTenant(sessionID))
|
|
651
1024
|
}
|
|
652
1025
|
|
|
653
1026
|
/** True while some device reported the session's tree visible within PRESENCE_WINDOW_MS. */
|
|
654
1027
|
watched(sessionID: string): boolean {
|
|
655
1028
|
const rootID = this.rootOf(sessionID) ?? sessionID
|
|
656
1029
|
const t = this.now()
|
|
657
|
-
for (const entry of this.presence.values()) {
|
|
1030
|
+
for (const entry of [...this.presence.values(), ...this.remotePresence]) {
|
|
658
1031
|
if (t - entry.at <= PRESENCE_WINDOW_MS && (this.rootOf(entry.sessionID) ?? entry.sessionID) === rootID) return true
|
|
659
1032
|
}
|
|
660
1033
|
return false
|
|
@@ -663,7 +1036,7 @@ export class Registry {
|
|
|
663
1036
|
/** Whether any process exposes the session to voice; undefined when no process reports it. */
|
|
664
1037
|
exposed(sessionID: string): boolean | undefined {
|
|
665
1038
|
let reported = false
|
|
666
|
-
for (const conn of this.
|
|
1039
|
+
for (const conn of this.all()) {
|
|
667
1040
|
const s = this.reports(conn, sessionID)
|
|
668
1041
|
if (!s) continue
|
|
669
1042
|
if (isOn(s)) return true
|
|
@@ -672,25 +1045,55 @@ export class Registry {
|
|
|
672
1045
|
return reported ? false : undefined
|
|
673
1046
|
}
|
|
674
1047
|
|
|
675
|
-
/** Process to change a session's exposure: a reporting service process, else the most recent reporter. */
|
|
1048
|
+
/** Process to change a session's exposure: a reporting service process, else the most recent reporter (never launch-bound). */
|
|
676
1049
|
exposeTarget(sessionID: string): string | undefined {
|
|
677
|
-
const reporters =
|
|
1050
|
+
const reporters = this.all().filter((c) => c.launchID === undefined && this.reports(c, sessionID))
|
|
678
1051
|
const services = reporters.filter((c) => c.hello.kind === "service")
|
|
679
1052
|
return this.recent(services.length ? services : reporters, sessionID)
|
|
680
1053
|
}
|
|
681
1054
|
|
|
682
|
-
/** The
|
|
683
|
-
|
|
684
|
-
|
|
1055
|
+
/** The launch a connection is bound to (undefined for ordinary connections and unknown ones). */
|
|
1056
|
+
launchOf(connID: string): string | undefined {
|
|
1057
|
+
return this.conn(connID)?.launchID
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** The (most recent) connection bound to a launch. */
|
|
1061
|
+
launchConn(launchID: string): string | undefined {
|
|
1062
|
+
let found: Conn | undefined
|
|
1063
|
+
for (const conn of this.all()) if (conn.launchID === launchID && (!found || conn.seq > found.seq)) found = conn
|
|
1064
|
+
return found?.id
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* The machine's (most recently connected) background service process of a harness (default OpenCode); never a
|
|
1069
|
+
* launch-bound connection. Each harness has its own service: a Claude Code service never hides the OpenCode one.
|
|
1070
|
+
*/
|
|
1071
|
+
serviceFor(machineID: string, harness: string = HARNESS_OPENCODE): string | undefined {
|
|
1072
|
+
const services = this.services(machineID).filter((c) => helloHarness(c.hello).harness === harness)
|
|
685
1073
|
return services.sort((a, b) => b.seq - a.seq)[0]?.id
|
|
686
1074
|
}
|
|
687
1075
|
|
|
1076
|
+
/** The harnesses that have a background service process (serviceFor) on the machine. */
|
|
1077
|
+
serviceHarnesses(machineID: string): string[] {
|
|
1078
|
+
return [...new Set(this.services(machineID).map((c) => helloHarness(c.hello).harness))]
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** A connection's harness ("opencode" for hellos that predate harness); undefined for an unknown connection. */
|
|
1082
|
+
harnessOf(connID: string): string | undefined {
|
|
1083
|
+
const conn = this.conn(connID)
|
|
1084
|
+
return conn && helloHarness(conn.hello).harness
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
private services(machineID: string): Conn[] {
|
|
1088
|
+
return this.all().filter((c) => c.machine.id === machineID && c.hello.kind === "service" && c.launchID === undefined)
|
|
1089
|
+
}
|
|
1090
|
+
|
|
688
1091
|
/**
|
|
689
1092
|
* True when the process reported a session in, or a workspace root equal to, the directory, or its
|
|
690
1093
|
* machine has a Herdr pane in it.
|
|
691
1094
|
*/
|
|
692
1095
|
reportsWorkspace(connID: string, directory: string): boolean {
|
|
693
|
-
const conn = this.
|
|
1096
|
+
const conn = this.conn(connID)
|
|
694
1097
|
if (!conn || !directory.startsWith("/")) return false
|
|
695
1098
|
const target = path.posix.resolve(directory)
|
|
696
1099
|
const herdr = this.terminals(conn.machine.id)?.herdr
|
|
@@ -703,10 +1106,36 @@ export class Registry {
|
|
|
703
1106
|
}
|
|
704
1107
|
|
|
705
1108
|
hasSession(sessionID: string): boolean {
|
|
706
|
-
for (const conn of this.
|
|
1109
|
+
for (const conn of this.all()) if (conn.sessions.has(sessionID)) return true
|
|
707
1110
|
return false
|
|
708
1111
|
}
|
|
709
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Idle by real evidence (message re-wakes): the owner process observes the harness natively or through hooks, or
|
|
1115
|
+
* through a harness RPC when it announces "turnEvents" (confirmed turn completion); screen and unknown evidence
|
|
1116
|
+
* never count. Nothing in the session's tree is working or blocked, no hub turn holds it, and a turn that ended by
|
|
1117
|
+
* an inferred interruption ended at least INFERRED_IDLE_GRACE_MS ago (the harness reported none, so the spoke's
|
|
1118
|
+
* guess may be followed at once by the next prompt's start).
|
|
1119
|
+
*/
|
|
1120
|
+
idle(sessionID: string): boolean {
|
|
1121
|
+
const owner = this.owner(sessionID)
|
|
1122
|
+
const conn = owner === undefined ? undefined : this.conn(owner)
|
|
1123
|
+
if (!conn) return false
|
|
1124
|
+
const { evidence } = helloHarness(conn.hello)
|
|
1125
|
+
const trusted = evidence === "native" || evidence === "hooks" || (evidence === "rpc" && conn.features.has("turnEvents"))
|
|
1126
|
+
if (!trusted) return false
|
|
1127
|
+
const inferred = this.inferredEnds.get(sessionID)
|
|
1128
|
+
if (inferred !== undefined && this.now() - inferred < INFERRED_IDLE_GRACE_MS) return false
|
|
1129
|
+
return !this.busy(sessionID) && this.browserFields(sessionID).state === "idle"
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** How recently a process sent an event for the session (higher: more recent; 0: never). */
|
|
1133
|
+
recency(sessionID: string): number {
|
|
1134
|
+
let latest = 0
|
|
1135
|
+
for (const conn of this.all()) latest = Math.max(latest, conn.activity.get(sessionID) ?? 0)
|
|
1136
|
+
return latest
|
|
1137
|
+
}
|
|
1138
|
+
|
|
710
1139
|
/** Something in the session's tree is working or waiting for the user (typed input steers then). */
|
|
711
1140
|
running(sessionID: string): boolean {
|
|
712
1141
|
const state = this.browserFields(sessionID).state
|
|
@@ -716,20 +1145,48 @@ export class Registry {
|
|
|
716
1145
|
/** Status tree of the root session that contains `sessionID`. */
|
|
717
1146
|
status(sessionID: string): LiveStatus[] | undefined {
|
|
718
1147
|
const board = this.boards.get(sessionID) ?? [...this.boards.values()].find((b) => b.get(sessionID))
|
|
719
|
-
|
|
1148
|
+
if (board) return this.live(board.snapshot().sessions)
|
|
1149
|
+
return this.remoteBoard(sessionID)?.sessions.map((s) => structuredClone(s))
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** Another pod's status board that contains the session. */
|
|
1153
|
+
private remoteBoard(sessionID: string): SliceBoard | undefined {
|
|
1154
|
+
if (!this.remoteBoards.size) return undefined
|
|
1155
|
+
const board = this.remoteBoards.get(sessionID)
|
|
1156
|
+
if (board) return board
|
|
1157
|
+
for (const candidate of this.remoteBoards.values()) if (candidate.sessions.some((s) => s.id === sessionID)) return candidate
|
|
1158
|
+
return undefined
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** A root's activity feed, here or (remote) on another pod. */
|
|
1162
|
+
private feed(rootID: string): readonly ActivityItem[] | undefined {
|
|
1163
|
+
return this.feeds.get(rootID) ?? (this.boards.has(rootID) ? undefined : this.remoteBoards.get(rootID)?.activity)
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** A hub turn runs for the session, on this pod or another. */
|
|
1167
|
+
private busy(sessionID: string): boolean {
|
|
1168
|
+
return this.active.has(sessionID) || this.remoteBusy.has(sessionID)
|
|
720
1169
|
}
|
|
721
1170
|
|
|
722
|
-
/** Adds `since` (run start) to working entries. */
|
|
1171
|
+
/** Adds `since` (run start) to working entries and agents' recommendations to permission blockers. */
|
|
723
1172
|
private live(sessions: SessionStatus[]): LiveStatus[] {
|
|
724
1173
|
return sessions.map((s) => {
|
|
725
1174
|
const since = s.state === "working" ? this.since.get(s.id) : undefined
|
|
726
|
-
|
|
1175
|
+
const recommended = s.blockers.some((b) => b.kind === "permission" && this.permissions.get(b.id)?.recommendation)
|
|
1176
|
+
const blockers = recommended
|
|
1177
|
+
? s.blockers.map((b) => {
|
|
1178
|
+
const recommendation = b.kind === "permission" ? this.permissions.get(b.id)?.recommendation : undefined
|
|
1179
|
+
return recommendation ? { ...b, recommendation } : b
|
|
1180
|
+
})
|
|
1181
|
+
: s.blockers
|
|
1182
|
+
const entry = recommended ? { ...s, blockers } : s
|
|
1183
|
+
return since === undefined ? entry : { ...entry, since }
|
|
727
1184
|
})
|
|
728
1185
|
}
|
|
729
1186
|
|
|
730
1187
|
catalog(): CatalogMachine[] {
|
|
731
1188
|
const machines = new Map<string, { id: string; name: string; processes: CatalogProcess[] }>()
|
|
732
|
-
for (const conn of
|
|
1189
|
+
for (const conn of this.all().sort((a, b) => a.seq - b.seq)) {
|
|
733
1190
|
const machine = machines.get(conn.machine.id) ?? { id: conn.machine.id, name: conn.machine.name, processes: [] }
|
|
734
1191
|
machines.set(conn.machine.id, machine)
|
|
735
1192
|
const terminals = this.terminals(conn.machine.id)
|
|
@@ -741,8 +1198,10 @@ export class Registry {
|
|
|
741
1198
|
directory,
|
|
742
1199
|
pid,
|
|
743
1200
|
mode,
|
|
744
|
-
opencodeVersion,
|
|
1201
|
+
opencodeVersion: opencodeVersion ?? "",
|
|
745
1202
|
pluginVersion,
|
|
1203
|
+
...helloHarness(conn.hello),
|
|
1204
|
+
features: [...conn.features].sort(),
|
|
746
1205
|
connected: conn.connected,
|
|
747
1206
|
lastSeen: conn.lastSeen,
|
|
748
1207
|
sessions: this.visible(conn).map((s) => {
|
|
@@ -753,7 +1212,7 @@ export class Registry {
|
|
|
753
1212
|
processID,
|
|
754
1213
|
machineID: conn.machine.id,
|
|
755
1214
|
status: this.status(s.id) ?? [],
|
|
756
|
-
busy: this.
|
|
1215
|
+
busy: this.busy(s.id),
|
|
757
1216
|
conversation: this.conversations.has(s.id),
|
|
758
1217
|
}
|
|
759
1218
|
}),
|
|
@@ -767,7 +1226,7 @@ export class Registry {
|
|
|
767
1226
|
let info: SessionInfo | undefined
|
|
768
1227
|
/** Open from any reporting process; a Herdr pane wins over a TUI. */
|
|
769
1228
|
let terminal: TerminalInfo | undefined
|
|
770
|
-
for (const conn of this.
|
|
1229
|
+
for (const conn of this.all()) {
|
|
771
1230
|
const s = this.reports(conn, sessionID)
|
|
772
1231
|
if (s && (!info || s.updated > info.updated)) info = s
|
|
773
1232
|
if (s?.open && s.terminal && (!terminal || (terminal.source !== "herdr" && s.terminal.source === "herdr"))) terminal = s.terminal
|
|
@@ -786,7 +1245,7 @@ export class Registry {
|
|
|
786
1245
|
updated: info.updated,
|
|
787
1246
|
...(terminal ? { open: true, terminal } : {}),
|
|
788
1247
|
...fields,
|
|
789
|
-
busy: this.
|
|
1248
|
+
busy: this.busy(sessionID),
|
|
790
1249
|
conversation: this.conversations.has(sessionID),
|
|
791
1250
|
status,
|
|
792
1251
|
activity: this.activity(sessionID),
|
|
@@ -812,7 +1271,7 @@ export class Registry {
|
|
|
812
1271
|
...(root.projectName ? { projectName: root.projectName } : {}),
|
|
813
1272
|
updated: root.updated,
|
|
814
1273
|
...this.browserFields(sessionID),
|
|
815
|
-
busy: this.
|
|
1274
|
+
busy: this.busy(sessionID),
|
|
816
1275
|
conversation: this.conversations.has(sessionID),
|
|
817
1276
|
status: root.status,
|
|
818
1277
|
activity: root.activity,
|
|
@@ -844,7 +1303,7 @@ export class Registry {
|
|
|
844
1303
|
: "idle"
|
|
845
1304
|
const blockers = entries.flatMap((e) => e.blockers.map((b) => ({ ...b, sessionID: e.id, sessionTitle: e.title })))
|
|
846
1305
|
const rootID = this.rootOf(sessionID)
|
|
847
|
-
const item = [...((rootID && this.
|
|
1306
|
+
const item = [...((rootID && this.feed(rootID)) || [])].reverse().find((i) => i.state === "running" && tree.has(i.sessionID))
|
|
848
1307
|
const now = item && { kind: item.kind, ...(item.tool ? { tool: item.tool } : {}), ...(item.summary ? { summary: item.summary } : {}) }
|
|
849
1308
|
return { exposed, state, blockers, ...(now ? { now } : {}) }
|
|
850
1309
|
}
|
|
@@ -852,14 +1311,14 @@ export class Registry {
|
|
|
852
1311
|
/** Recent activity of the root session that contains `sessionID`, newest last. */
|
|
853
1312
|
activity(sessionID: string): ActivityItem[] {
|
|
854
1313
|
const rootID = this.rootOf(sessionID)
|
|
855
|
-
return rootID ? (this.
|
|
1314
|
+
return rootID ? (this.feed(rootID) ?? []).map((item) => ({ ...item })) : []
|
|
856
1315
|
}
|
|
857
1316
|
|
|
858
1317
|
/** Root session of the status board that contains `sessionID`. */
|
|
859
|
-
|
|
1318
|
+
rootOf(sessionID: string): string | undefined {
|
|
860
1319
|
if (this.boards.has(sessionID)) return sessionID
|
|
861
1320
|
for (const [rootID, board] of this.boards) if (board.get(sessionID)) return rootID
|
|
862
|
-
return
|
|
1321
|
+
return this.remoteBoard(sessionID)?.rootID
|
|
863
1322
|
}
|
|
864
1323
|
|
|
865
1324
|
/**
|
|
@@ -892,11 +1351,16 @@ export class Registry {
|
|
|
892
1351
|
const id = text(data.id)
|
|
893
1352
|
if (!id) return
|
|
894
1353
|
const summary = text(data.summary)
|
|
895
|
-
const
|
|
1354
|
+
const childSessionID = text(data.childSessionID)
|
|
1355
|
+
// A compact progress event (childSessionID only) adds the child to the item without touching the rest.
|
|
1356
|
+
const details = childSessionID && !("summary" in data)
|
|
1357
|
+
? { childSessionID }
|
|
1358
|
+
: { ...(summary ? { summary } : {}), ...(data.background === true ? { background: true as const } : {}), ...(childSessionID ? { childSessionID } : {}) }
|
|
896
1359
|
const existing = find(id)
|
|
897
1360
|
if (!existing) return add({ id, sessionID, kind: "tool", ...details, state: "running", at })
|
|
898
|
-
|
|
899
|
-
|
|
1361
|
+
const next: ActivityItem = { ...existing, ...details }
|
|
1362
|
+
if (next.summary === existing.summary && next.background === existing.background && next.childSessionID === existing.childSessionID) return
|
|
1363
|
+
return this.replace(rootID, existing, next)
|
|
900
1364
|
}
|
|
901
1365
|
case "session.tool.success":
|
|
902
1366
|
case "session.tool.failed": {
|
|
@@ -904,7 +1368,15 @@ export class Registry {
|
|
|
904
1368
|
if (!existing || existing.state !== "running") return
|
|
905
1369
|
const failed = event.type === "session.tool.failed"
|
|
906
1370
|
const error = failed ? (text(data.error) ?? "failed") : undefined
|
|
907
|
-
|
|
1371
|
+
const childSessionID = existing.childSessionID ?? text(data.childSessionID)
|
|
1372
|
+
const took = at - existing.at
|
|
1373
|
+
const name = existing.summary ?? existing.tool ?? "tool"
|
|
1374
|
+
if (took >= LONG_TOOL_MS) {
|
|
1375
|
+
this.step(rootID, "long", `${name} ${failed ? "failed" : "done"} (${Math.round(took / 1000)}s)`)
|
|
1376
|
+
// A subagent's tool call ends with the subagent (its own kinds).
|
|
1377
|
+
if (!childSessionID) this.alert("longTool", rootID, { subject: `${Math.round(took / 1000)}s`, text: `${name} ${failed ? "failed" : "done"}`, key: existing.id })
|
|
1378
|
+
} else this.step(rootID, "tool", name)
|
|
1379
|
+
return this.replace(rootID, existing, { ...existing, state: failed ? "failed" : "done", ...(error ? { error } : {}), ...(childSessionID ? { childSessionID } : {}), at })
|
|
908
1380
|
}
|
|
909
1381
|
case "session.reasoning.started":
|
|
910
1382
|
case "session.reasoning.delta": {
|
|
@@ -925,6 +1397,96 @@ export class Registry {
|
|
|
925
1397
|
}
|
|
926
1398
|
}
|
|
927
1399
|
|
|
1400
|
+
/**
|
|
1401
|
+
* Steps of interest for activity updates: a subagent starting or ending, a background job ending (with how), the
|
|
1402
|
+
* root's run ending. `before` is the session's status entry before the event (several processes forward the
|
|
1403
|
+
* same events: only actual changes count). Tool calls are reported from observeActivity.
|
|
1404
|
+
*/
|
|
1405
|
+
private activitySteps(type: string, sessionID: string, data: Record<string, unknown>, before: LiveStatus | undefined): void {
|
|
1406
|
+
const rootID = this.rootOf(sessionID)
|
|
1407
|
+
if (!rootID || !before) return
|
|
1408
|
+
const child = rootID !== sessionID
|
|
1409
|
+
const title = before.title?.trim() || "A subagent"
|
|
1410
|
+
if (type === "session.execution.started" && child && before.state !== "working") return this.step(rootID, "subagent", `${title} started`)
|
|
1411
|
+
if (type.startsWith("session.execution.") && type !== "session.execution.started") {
|
|
1412
|
+
const ended = { succeeded: "finished", failed: "failed", interrupted: "stopped" }[type.slice("session.execution.".length)]
|
|
1413
|
+
if (!ended || before.state !== "working") return
|
|
1414
|
+
if (child) return this.step(rootID, "subagent", `${title} ${ended}`)
|
|
1415
|
+
return this.step(rootID, "idle", ended === "finished" ? "Finished" : ended === "failed" ? "Failed" : "Stopped")
|
|
1416
|
+
}
|
|
1417
|
+
if (type === "shell.exited" || type === "session.shell.ended" || type === "pty.exited") {
|
|
1418
|
+
const job = before.jobs?.find((j) => j.id === data.id)
|
|
1419
|
+
if (!job || job.status !== "running") return
|
|
1420
|
+
const exit = typeof data.exit === "number" ? data.exit : undefined
|
|
1421
|
+
const how = data.status === "killed" ? "was killed" : data.status === "timeout" ? "timed out" : exit !== undefined ? `exited ${exit}` : "exited"
|
|
1422
|
+
return this.step(rootID, "job", `${job.command ?? (job.kind === "pty" ? "terminal" : "shell")} ${how}`)
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Notifications from run, subagent, job and status events (docs/notifications.md). `before` is the session's status
|
|
1428
|
+
* entry before the event: several processes forward the same events, so subagent and job notifications need an
|
|
1429
|
+
* actual change; root outcomes are deduped by the push cooldown.
|
|
1430
|
+
*/
|
|
1431
|
+
private notifications(type: string, sessionID: string, data: Record<string, unknown>, before: LiveStatus | undefined): void {
|
|
1432
|
+
const rootID = this.rootOf(sessionID)
|
|
1433
|
+
if (!rootID) return
|
|
1434
|
+
const root = rootID === sessionID
|
|
1435
|
+
const wasRunning = before?.state === "working" || before?.state === "blocked"
|
|
1436
|
+
if (type === "session.execution.started") return this.doneWatch.started(rootID, root)
|
|
1437
|
+
if (type === "session.text.delta" || type === "session.reasoning.delta") return
|
|
1438
|
+
const ended = type === "session.execution.succeeded" ? "succeeded" : type === "session.execution.failed" ? "failed" : type === "session.execution.interrupted" ? "interrupted" : undefined
|
|
1439
|
+
const reason = (data.error as { message?: unknown } | undefined)?.message
|
|
1440
|
+
const error = typeof reason === "string" && reason.trim() ? reason : undefined
|
|
1441
|
+
if (ended && root) {
|
|
1442
|
+
this.doneWatch.rootEnded(rootID, ended, wasRunning)
|
|
1443
|
+
if (ended === "succeeded" && wasRunning && !this.active.has(rootID)) this.alert("turn", rootID)
|
|
1444
|
+
if (ended === "failed") this.alert("failed", rootID, error ? { text: error } : {})
|
|
1445
|
+
const stopped = ended === "interrupted" ? STOP_REASONS[String(data.reason)] : undefined
|
|
1446
|
+
if (stopped) this.alert("stopped", rootID, { text: stopped })
|
|
1447
|
+
return
|
|
1448
|
+
}
|
|
1449
|
+
this.doneWatch.poke(rootID)
|
|
1450
|
+
if (ended && wasRunning && ended !== "interrupted") {
|
|
1451
|
+
const subject = before!.title?.trim() || "subagent"
|
|
1452
|
+
const parent = (before!.parentID && this.status(rootID)?.find((s) => s.id === before!.parentID)?.title?.trim()) || "the session"
|
|
1453
|
+
if (ended === "succeeded") this.alert("subagentDone", rootID, { subject, text: `In ${parent}`, key: sessionID })
|
|
1454
|
+
else this.alert("subagentFailed", rootID, { subject, text: error ?? `In ${parent}`, key: sessionID })
|
|
1455
|
+
return
|
|
1456
|
+
}
|
|
1457
|
+
if (type === "session.status" && root && !before?.retry) {
|
|
1458
|
+
const retry = this.status(rootID)?.find((s) => s.id === rootID)?.retry
|
|
1459
|
+
if (retry) this.alert("retry", rootID, { text: [retry.attempt !== undefined ? `Attempt ${retry.attempt}` : "", retry.message ?? ""].filter(Boolean).join(": ") || "Retrying" })
|
|
1460
|
+
return
|
|
1461
|
+
}
|
|
1462
|
+
if (type === "shell.exited" || type === "session.shell.ended" || type === "pty.exited") {
|
|
1463
|
+
const job = before?.jobs?.find((j) => j.id === data.id)
|
|
1464
|
+
if (!job || job.status !== "running") return
|
|
1465
|
+
const exit = typeof data.exit === "number" ? data.exit : undefined
|
|
1466
|
+
const how = data.status === "killed" ? "was killed" : data.status === "timeout" ? "timed out" : exit !== undefined ? `exited ${exit}` : "exited"
|
|
1467
|
+
const failed = data.status === "killed" || data.status === "timeout" || (exit !== undefined && exit !== 0)
|
|
1468
|
+
this.alert(failed ? "jobFailed" : "jobDone", rootID, { subject: how, text: job.command ?? (job.kind === "pty" ? "terminal" : "shell"), key: job.id })
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** Fires when a root's tree stayed settled after its run (DoneWatch). */
|
|
1473
|
+
private settledTree(rootID: string): boolean {
|
|
1474
|
+
const board = this.boards.get(rootID)
|
|
1475
|
+
if (!board?.settled()) return false
|
|
1476
|
+
for (const id of this.active.keys()) if ((this.rootOf(id) ?? id) === rootID) return false
|
|
1477
|
+
return true
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** Reports an activity step of a root session (not while a device shows it). */
|
|
1481
|
+
private step(rootID: string, kind: ActivityStep["kind"], text: string): void {
|
|
1482
|
+
if (this.watched(rootID)) return
|
|
1483
|
+
const board = this.boards.get(rootID)
|
|
1484
|
+
if (!board) return
|
|
1485
|
+
const owner = this.sessionTenant(rootID)
|
|
1486
|
+
const alert: Alert = { kind: "activity", sessionID: rootID, title: board.get(rootID)?.title ?? rootID, step: { kind, text } }
|
|
1487
|
+
for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
|
|
1488
|
+
}
|
|
1489
|
+
|
|
928
1490
|
/** Thinking also ends when something else happens in the same session (older spokes send no reasoning.ended). */
|
|
929
1491
|
private closeThinking(rootID: string, sessionID: string): void {
|
|
930
1492
|
for (const item of this.feeds.get(rootID) ?? []) {
|
|
@@ -957,12 +1519,23 @@ export class Registry {
|
|
|
957
1519
|
else {
|
|
958
1520
|
this.conversations.delete(sessionID)
|
|
959
1521
|
for (const [childID, state] of this.workerSpeech) if (state.rootID === sessionID) this.clearWorkerSpeech(childID)
|
|
1522
|
+
this.dropRootSpeech(sessionID)
|
|
960
1523
|
}
|
|
961
1524
|
if (!changed) return
|
|
962
1525
|
this.emit({ type: "conversation", sessionID, on })
|
|
963
1526
|
this.emit({ type: "catalog" })
|
|
964
1527
|
}
|
|
965
1528
|
|
|
1529
|
+
/**
|
|
1530
|
+
* Whether a connected process serves a feature (spokeFeatures): a core op (CORE_OPS: turn, steer, interrupt,
|
|
1531
|
+
* stop, permission, form), which an OpenCode spoke implies and a spoke of another harness serves only when it
|
|
1532
|
+
* announced it, or an optional feature, which it announced in its hello (or, for an OpenCode spoke that predates
|
|
1533
|
+
* harness, one in IMPLIED_OPENCODE_FEATURES). Callers answer unsupported {feature, harness} (harnessOf) without it.
|
|
1534
|
+
*/
|
|
1535
|
+
supports(connID: string, feature: SpokeFeature): boolean {
|
|
1536
|
+
return this.conn(connID)?.features.has(feature) === true
|
|
1537
|
+
}
|
|
1538
|
+
|
|
966
1539
|
/**
|
|
967
1540
|
* Connection that should run a turn for the session:
|
|
968
1541
|
* 1. a process reporting it as owned, most recent activity first;
|
|
@@ -971,9 +1544,10 @@ export class Registry {
|
|
|
971
1544
|
* 4. else any process that reports it, most recent activity first;
|
|
972
1545
|
* 5. else none.
|
|
973
1546
|
*/
|
|
1547
|
+
|
|
974
1548
|
owner(sessionID: string): string | undefined {
|
|
975
1549
|
const tenant = this.sessionTenant(sessionID)
|
|
976
|
-
const all =
|
|
1550
|
+
const all = this.all().filter((c) => this.tenant(c) === tenant)
|
|
977
1551
|
const reporters = all.filter((c) => this.reports(c, sessionID))
|
|
978
1552
|
if (reporters.length === 0) {
|
|
979
1553
|
// A subagent session no process lists runs in the process that runs its root.
|
|
@@ -989,6 +1563,20 @@ export class Registry {
|
|
|
989
1563
|
return recent(all.filter((c) => c.hello.kind === "service" && machines.has(c.machine.id))) ?? recent(reporters)
|
|
990
1564
|
}
|
|
991
1565
|
|
|
1566
|
+
/**
|
|
1567
|
+
* Connections to try for a session op that any reporter can carry out (e.g. rename): the owner first, then
|
|
1568
|
+
* the other processes of the session's tenant that report it, most recent activity first.
|
|
1569
|
+
*/
|
|
1570
|
+
sessionTargets(sessionID: string): string[] {
|
|
1571
|
+
const owner = this.owner(sessionID)
|
|
1572
|
+
if (!owner) return []
|
|
1573
|
+
const tenant = this.sessionTenant(sessionID)
|
|
1574
|
+
const others = this.all()
|
|
1575
|
+
.filter((c) => c.id !== owner && this.tenant(c) === tenant && this.reports(c, sessionID))
|
|
1576
|
+
.sort((a, b) => (b.activity.get(sessionID) ?? 0) - (a.activity.get(sessionID) ?? 0) || b.seq - a.seq)
|
|
1577
|
+
return [owner, ...others.map((c) => c.id)]
|
|
1578
|
+
}
|
|
1579
|
+
|
|
992
1580
|
/** The connection with the most recent activity for the session, then the most recently connected. */
|
|
993
1581
|
private recent(list: Conn[], sessionID: string): string | undefined {
|
|
994
1582
|
return [...list].sort((a, b) => (b.activity.get(sessionID) ?? 0) - (a.activity.get(sessionID) ?? 0) || b.seq - a.seq)[0]?.id
|
|
@@ -1004,22 +1592,36 @@ export class Registry {
|
|
|
1004
1592
|
const pending = this.status(rootID)?.some((s) =>
|
|
1005
1593
|
s.blockers.some((b) => b.kind === "permission" && b.id === requestID),
|
|
1006
1594
|
)
|
|
1007
|
-
const
|
|
1595
|
+
const local = this.permissions.get(requestID)
|
|
1596
|
+
const remote = this.remotePermissions.get(requestID)
|
|
1597
|
+
const origin = local ?? remote
|
|
1008
1598
|
if (!pending || !origin) return []
|
|
1009
1599
|
const tenant = this.sessionTenant(rootID)
|
|
1010
|
-
return
|
|
1011
|
-
.filter((connID) => this.
|
|
1600
|
+
return [...new Set([...(local?.connIDs ?? []), ...(remote?.connIDs ?? [])])]
|
|
1601
|
+
.filter((connID) => this.conn(connID) !== undefined && this.connTenant(connID) === tenant)
|
|
1012
1602
|
.map((connID) => ({ connID, sessionID: origin.sessionID }))
|
|
1013
1603
|
}
|
|
1014
1604
|
|
|
1605
|
+
/**
|
|
1606
|
+
* A pending permission: the session that asked and what it asks (bounded, without metadata; absent when the
|
|
1607
|
+
* request had no usable action). Callers check tenancy (owns) and that it is still pending in the tree.
|
|
1608
|
+
*/
|
|
1609
|
+
permissionRequest(requestID: string): { sessionID: string; request?: PermissionRequest } | undefined {
|
|
1610
|
+
const origin = this.permissions.get(requestID) ?? this.remotePermissions.get(requestID)
|
|
1611
|
+
if (!origin) return undefined
|
|
1612
|
+
return { sessionID: origin.sessionID, ...(origin.request ? { request: structuredClone(origin.request) } : {}) }
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1015
1615
|
/** Every connected process of the session's tenant that forwarded the pending form, first first. */
|
|
1016
1616
|
formTargets(rootID: string, formID: string): Array<{ connID: string; sessionID: string }> {
|
|
1017
1617
|
const pending = this.status(rootID)?.some((s) => s.blockers.some((b) => b.kind === "form" && b.id === formID))
|
|
1018
|
-
const
|
|
1618
|
+
const local = this.forms.get(formID)
|
|
1619
|
+
const remote = this.remoteForms.get(formID)
|
|
1620
|
+
const origin = local ?? remote
|
|
1019
1621
|
if (!pending || !origin) return []
|
|
1020
1622
|
const tenant = this.sessionTenant(rootID)
|
|
1021
|
-
return
|
|
1022
|
-
.filter((connID) => this.
|
|
1623
|
+
return [...new Set([...(local?.connIDs ?? []), ...(remote?.connIDs ?? [])])]
|
|
1624
|
+
.filter((connID) => this.conn(connID) !== undefined && this.connTenant(connID) === tenant)
|
|
1023
1625
|
.map((connID) => ({ connID, sessionID: origin.sessionID }))
|
|
1024
1626
|
}
|
|
1025
1627
|
|
|
@@ -1029,6 +1631,7 @@ export class Registry {
|
|
|
1029
1631
|
if (!origin) return
|
|
1030
1632
|
this.forms.delete(formID)
|
|
1031
1633
|
this.observeBoards({ type: "form.cancelled", data: { sessionID: origin.sessionID, id: formID } })
|
|
1634
|
+
this.changed(this.sessionTenant(origin.sessionID))
|
|
1032
1635
|
}
|
|
1033
1636
|
|
|
1034
1637
|
/** A permission no process holds any more (answered elsewhere, expired): drop it and its blocker. */
|
|
@@ -1037,6 +1640,45 @@ export class Registry {
|
|
|
1037
1640
|
if (!origin) return
|
|
1038
1641
|
this.permissions.delete(requestID)
|
|
1039
1642
|
this.observeBoards({ type: "permission.replied", data: { sessionID: origin.sessionID, requestID } })
|
|
1643
|
+
this.changed(origin.tenant ?? this.sessionTenant(origin.sessionID))
|
|
1644
|
+
this.permissionClosed(requestID, origin)
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/** Listens for pending permissions going away (replied, dropped, every process holding one gone). */
|
|
1648
|
+
onPermissionClosed(listener: (requestID: string, sessionID: string, tenant: string | undefined) => void): () => void {
|
|
1649
|
+
this.permissionClosedListeners.add(listener)
|
|
1650
|
+
return () => this.permissionClosedListeners.delete(listener)
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* Shows an agent's recommendation on a pending permission's blocker (sanitised; the status goes out again);
|
|
1655
|
+
* false when no such request is pending. It goes with the request.
|
|
1656
|
+
*/
|
|
1657
|
+
recommend(requestID: string, recommendation: BlockerRecommendation): boolean {
|
|
1658
|
+
const origin = this.permissions.get(requestID)
|
|
1659
|
+
if (!origin) return false
|
|
1660
|
+
const why = recommendation.why === undefined ? "" : sanitizeLabel(recommendation.why, RECOMMENDATION_WHY_MAX)
|
|
1661
|
+
origin.recommendation = {
|
|
1662
|
+
by: sanitizeLabel(recommendation.by, RECOMMENDATION_BY_MAX) || "An agent",
|
|
1663
|
+
recommends: "approve",
|
|
1664
|
+
...(why ? { why } : {}),
|
|
1665
|
+
...(recommendation.decisionID && DECISION_ID.test(recommendation.decisionID) ? { decisionID: recommendation.decisionID } : {}),
|
|
1666
|
+
}
|
|
1667
|
+
const rootID = this.rootOf(origin.sessionID)
|
|
1668
|
+
const board = rootID === undefined ? undefined : this.boards.get(rootID)
|
|
1669
|
+
if (rootID !== undefined && board) this.emit({ type: "status", sessionID: rootID, sessions: this.live(board.snapshot().sessions) })
|
|
1670
|
+
this.changed(origin.tenant ?? this.sessionTenant(origin.sessionID))
|
|
1671
|
+
return true
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
private permissionClosed(requestID: string, origin: PendingPermission): void {
|
|
1675
|
+
for (const listener of [...this.permissionClosedListeners]) {
|
|
1676
|
+
try {
|
|
1677
|
+
listener(requestID, origin.sessionID, origin.tenant)
|
|
1678
|
+
} catch (cause) {
|
|
1679
|
+
console.error(`[hub] permission ${requestID}: closed listener failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1040
1682
|
}
|
|
1041
1683
|
|
|
1042
1684
|
/**
|
|
@@ -1044,14 +1686,17 @@ export class Registry {
|
|
|
1044
1686
|
* interrupt releases the lock first (see cancelTurns), so the waiting request can end.
|
|
1045
1687
|
*/
|
|
1046
1688
|
beginTurn(sessionID: string, onCancel?: () => void): () => void {
|
|
1047
|
-
if (this.
|
|
1689
|
+
if (this.busy(sessionID)) throw new BusyError()
|
|
1048
1690
|
const turn = { cancel: onCancel }
|
|
1049
1691
|
this.active.set(sessionID, turn)
|
|
1692
|
+
this.changed(this.sessionTenant(sessionID))
|
|
1050
1693
|
this.emit({ type: "catalog" })
|
|
1051
1694
|
return () => {
|
|
1052
1695
|
if (this.active.get(sessionID) !== turn) return // already released, or cancelled and taken by a newer turn
|
|
1053
1696
|
this.active.delete(sessionID)
|
|
1697
|
+
this.changed(this.sessionTenant(sessionID))
|
|
1054
1698
|
this.emit({ type: "catalog" })
|
|
1699
|
+
this.doneWatch.poke(this.rootOf(sessionID) ?? sessionID)
|
|
1055
1700
|
}
|
|
1056
1701
|
}
|
|
1057
1702
|
|
|
@@ -1061,6 +1706,7 @@ export class Registry {
|
|
|
1061
1706
|
const ended = [...this.active].filter(([id]) => id === sessionID || (this.rootOf(id) ?? id) === rootID)
|
|
1062
1707
|
if (!ended.length) return
|
|
1063
1708
|
for (const [id] of ended) this.active.delete(id)
|
|
1709
|
+
this.changed(this.sessionTenant(sessionID))
|
|
1064
1710
|
this.emit({ type: "catalog" })
|
|
1065
1711
|
for (const [, turn] of ended) {
|
|
1066
1712
|
try {
|
|
@@ -1097,7 +1743,11 @@ export class Registry {
|
|
|
1097
1743
|
}
|
|
1098
1744
|
}
|
|
1099
1745
|
|
|
1100
|
-
for (const id of this.boards.keys())
|
|
1746
|
+
for (const id of this.boards.keys()) {
|
|
1747
|
+
if (reported.has(id) && rootOf(id) === id) continue
|
|
1748
|
+
this.boards.delete(id)
|
|
1749
|
+
this.doneWatch.forget(id)
|
|
1750
|
+
}
|
|
1101
1751
|
|
|
1102
1752
|
const children: SessionInfo[] = []
|
|
1103
1753
|
for (const s of reported.values()) {
|
|
@@ -1130,17 +1780,177 @@ export class Registry {
|
|
|
1130
1780
|
}
|
|
1131
1781
|
}
|
|
1132
1782
|
|
|
1133
|
-
|
|
1783
|
+
// ---------- cluster (design §9) ----------
|
|
1784
|
+
|
|
1785
|
+
/** Listens for changes to this pod's state of a tenant (undefined: any tenant may have changed). */
|
|
1786
|
+
onLocalChange(listener: (tenant: string | undefined) => void): () => void {
|
|
1787
|
+
this.localChangeListeners.add(listener)
|
|
1788
|
+
return () => this.localChangeListeners.delete(listener)
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
private changed(tenant: string | undefined): void {
|
|
1792
|
+
for (const listener of [...this.localChangeListeners]) {
|
|
1793
|
+
try {
|
|
1794
|
+
listener(tenant)
|
|
1795
|
+
} catch (cause) {
|
|
1796
|
+
console.error(`[hub] registry change listener failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** Tenants this pod holds state of (connections, boards, pending permissions and forms, presence, turns). */
|
|
1802
|
+
clusterTenants(): string[] {
|
|
1803
|
+
const tenants = new Set<string>()
|
|
1804
|
+
for (const conn of this.conns.values()) tenants.add(this.tenant(conn))
|
|
1805
|
+
const add = (tenant: string | undefined) => tenant !== undefined && tenants.add(tenant)
|
|
1806
|
+
for (const rootID of this.boards.keys()) add(this.sessionTenant(rootID))
|
|
1807
|
+
for (const origin of this.permissions.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
|
|
1808
|
+
for (const origin of this.forms.values()) add(this.sessionTenant(origin.sessionID))
|
|
1809
|
+
for (const entry of this.presence.values()) add(this.sessionTenant(entry.sessionID))
|
|
1810
|
+
for (const sessionID of this.active.keys()) add(this.sessionTenant(sessionID))
|
|
1811
|
+
return [...tenants]
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/** This pod's state of a tenant for other pods (undefined when there is none). Remote state is not included. */
|
|
1815
|
+
clusterSlice(tenant: string): RegistrySlice | undefined {
|
|
1816
|
+
const conns: SliceConn[] = [...this.conns.values()]
|
|
1817
|
+
.filter((conn) => this.tenant(conn) === tenant)
|
|
1818
|
+
.map((conn) => {
|
|
1819
|
+
const terminals = this.machineTerminals.get(conn.machine.id)
|
|
1820
|
+
return {
|
|
1821
|
+
id: conn.id,
|
|
1822
|
+
machine: { id: conn.machine.id, name: conn.machine.name },
|
|
1823
|
+
hello: conn.hello,
|
|
1824
|
+
connected: conn.connected,
|
|
1825
|
+
seq: conn.seq,
|
|
1826
|
+
lastSeen: conn.lastSeen,
|
|
1827
|
+
sessions: [...conn.sessions.values()],
|
|
1828
|
+
activity: [...conn.activity],
|
|
1829
|
+
...(conn.launchID !== undefined ? { launchID: conn.launchID } : {}),
|
|
1830
|
+
...(terminals?.connID === conn.id ? { terminals: terminals.terminals } : {}),
|
|
1831
|
+
}
|
|
1832
|
+
})
|
|
1833
|
+
const boards: SliceBoard[] = [...this.boards]
|
|
1834
|
+
.filter(([rootID]) => this.sessionTenant(rootID) === tenant)
|
|
1835
|
+
.map(([rootID, board]) => ({ rootID, sessions: this.live(board.snapshot().sessions), activity: this.feeds.get(rootID) ?? [] }))
|
|
1836
|
+
const permissions: SlicePermission[] = [...this.permissions]
|
|
1837
|
+
.filter(([, origin]) => (origin.tenant ?? this.sessionTenant(origin.sessionID)) === tenant)
|
|
1838
|
+
.map(([requestID, origin]) => ({
|
|
1839
|
+
requestID,
|
|
1840
|
+
sessionID: origin.sessionID,
|
|
1841
|
+
connIDs: [...origin.connIDs],
|
|
1842
|
+
...(origin.request ? { request: origin.request } : {}),
|
|
1843
|
+
...(origin.recommendation ? { recommendation: origin.recommendation } : {}),
|
|
1844
|
+
}))
|
|
1845
|
+
const forms: SliceForm[] = [...this.forms]
|
|
1846
|
+
.filter(([, origin]) => this.sessionTenant(origin.sessionID) === tenant)
|
|
1847
|
+
.map(([formID, origin]) => ({ formID, sessionID: origin.sessionID, connIDs: [...origin.connIDs] }))
|
|
1848
|
+
const t = this.now()
|
|
1849
|
+
const presence = [...this.presence]
|
|
1850
|
+
.filter(([, entry]) => t - entry.at <= PRESENCE_WINDOW_MS && this.sessionTenant(entry.sessionID) === tenant)
|
|
1851
|
+
.map(([key, entry]) => ({ deviceID: key.slice(0, key.lastIndexOf(` ${entry.sessionID}`)), sessionID: entry.sessionID, at: entry.at }))
|
|
1852
|
+
const busy = [...this.active.keys()].filter((sessionID) => this.sessionTenant(sessionID) === tenant)
|
|
1853
|
+
if (!conns.length && !boards.length && !permissions.length && !forms.length && !presence.length && !busy.length) return undefined
|
|
1854
|
+
return structuredClone({ conns, boards, permissions, forms, presence, busy })
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Applies another pod's slice of a tenant (undefined: it has none any more, e.g. the pod is gone). Remote
|
|
1859
|
+
* connections answer the reads like local ones; listeners get a catalog message.
|
|
1860
|
+
*/
|
|
1861
|
+
setRemote(pod: string, tenant: string, slice: RegistrySlice | undefined): void {
|
|
1862
|
+
let slices = this.remoteSlices.get(pod)
|
|
1863
|
+
if (slice) {
|
|
1864
|
+
if (!slices) this.remoteSlices.set(pod, (slices = new Map()))
|
|
1865
|
+
slices.set(tenant, slice)
|
|
1866
|
+
} else {
|
|
1867
|
+
if (!slices?.delete(tenant)) return
|
|
1868
|
+
if (!slices.size) this.remoteSlices.delete(pod)
|
|
1869
|
+
}
|
|
1870
|
+
this.reindex()
|
|
1871
|
+
this.emit({ type: "catalog" })
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/** The pods whose slices are applied here, with their tenants. */
|
|
1875
|
+
remotePods(): Map<string, string[]> {
|
|
1876
|
+
return new Map([...this.remoteSlices].map(([pod, slices]) => [pod, [...slices.keys()]]))
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/** Rebuilds the indexes of the remote slices. */
|
|
1880
|
+
private reindex(): void {
|
|
1881
|
+
const conns = new Map<string, Conn>()
|
|
1882
|
+
const boards = new Map<string, SliceBoard>()
|
|
1883
|
+
const permissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
|
|
1884
|
+
const forms = new Map<string, { sessionID: string; connIDs: string[] }>()
|
|
1885
|
+
const presence: Array<{ sessionID: string; at: number }> = []
|
|
1886
|
+
const busy = new Set<string>()
|
|
1887
|
+
const terminals = new Map<string, MachineTerminals>()
|
|
1888
|
+
for (const [pod, slices] of this.remoteSlices) {
|
|
1889
|
+
for (const [tenant, slice] of slices) {
|
|
1890
|
+
for (const c of slice.conns ?? []) {
|
|
1891
|
+
if (this.conns.has(c.id)) continue
|
|
1892
|
+
conns.set(c.id, {
|
|
1893
|
+
id: c.id,
|
|
1894
|
+
machine: c.machine,
|
|
1895
|
+
hello: c.hello,
|
|
1896
|
+
features: spokeFeatures(c.hello),
|
|
1897
|
+
connected: c.connected,
|
|
1898
|
+
seq: c.seq,
|
|
1899
|
+
lastSeen: c.lastSeen,
|
|
1900
|
+
sessions: new Map((c.sessions ?? []).map((s) => [s.id, s])),
|
|
1901
|
+
sessionsKey: "",
|
|
1902
|
+
activity: new Map(c.activity ?? []),
|
|
1903
|
+
...(c.launchID !== undefined ? { launchID: c.launchID } : {}),
|
|
1904
|
+
remote: { pod, tenant },
|
|
1905
|
+
})
|
|
1906
|
+
if (c.terminals) terminals.set(c.machine.id, c.terminals)
|
|
1907
|
+
}
|
|
1908
|
+
for (const board of slice.boards ?? []) boards.set(board.rootID, board)
|
|
1909
|
+
for (const p of slice.permissions ?? []) {
|
|
1910
|
+
const known = permissions.get(p.requestID)
|
|
1911
|
+
if (known) known.connIDs.push(...p.connIDs.filter((id) => !known.connIDs.includes(id)))
|
|
1912
|
+
else permissions.set(p.requestID, { sessionID: p.sessionID, tenant, connIDs: [...p.connIDs], ...(p.request ? { request: p.request } : {}), ...(p.recommendation ? { recommendation: p.recommendation } : {}) })
|
|
1913
|
+
}
|
|
1914
|
+
for (const f of slice.forms ?? []) {
|
|
1915
|
+
const known = forms.get(f.formID)
|
|
1916
|
+
if (known) known.connIDs.push(...f.connIDs.filter((id) => !known.connIDs.includes(id)))
|
|
1917
|
+
else forms.set(f.formID, { sessionID: f.sessionID, connIDs: [...f.connIDs] })
|
|
1918
|
+
}
|
|
1919
|
+
for (const entry of slice.presence ?? []) presence.push({ sessionID: entry.sessionID, at: entry.at })
|
|
1920
|
+
for (const sessionID of slice.busy ?? []) busy.add(sessionID)
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
this.remoteConns = conns
|
|
1924
|
+
this.remoteBoards = boards
|
|
1925
|
+
this.remotePermissions = permissions
|
|
1926
|
+
this.remoteForms = forms
|
|
1927
|
+
this.remotePresence = presence
|
|
1928
|
+
this.remoteBusy = busy
|
|
1929
|
+
this.remoteTerminals = terminals
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/** Raises a notification about the session for its user (e.g. the delivery engine's unanswered asks). */
|
|
1933
|
+
alertUser(kind: NotificationKind, sessionID: string, fields: { text?: string; subject?: string; key?: string } = {}): void {
|
|
1934
|
+
this.alert(kind, sessionID, fields)
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Raises a notification for the root of `sessionID`'s tree. Only hand-offs, questions and permissions reach a
|
|
1939
|
+
* session some device shows (spoken replies check that themselves: the watching device speaks them).
|
|
1940
|
+
*/
|
|
1941
|
+
private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string } = {}): void {
|
|
1134
1942
|
const entry = [...this.boards].find(([, board]) => board.get(sessionID))
|
|
1135
1943
|
if (!entry) return
|
|
1136
1944
|
const [rootID, board] = entry
|
|
1945
|
+
if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return
|
|
1137
1946
|
const alert: Alert = {
|
|
1138
1947
|
kind,
|
|
1139
1948
|
sessionID: rootID,
|
|
1140
1949
|
title: board.get(rootID)?.title ?? rootID,
|
|
1141
|
-
...(
|
|
1142
|
-
...(text !== undefined ? { text } : {}),
|
|
1143
|
-
...(requestID !== undefined ? { requestID } : {}),
|
|
1950
|
+
...(fields.subject !== undefined ? { subject: fields.subject } : {}),
|
|
1951
|
+
...(fields.text !== undefined ? { text: fields.text } : {}),
|
|
1952
|
+
...(fields.requestID !== undefined ? { requestID: fields.requestID } : {}),
|
|
1953
|
+
...(fields.key !== undefined ? { key: fields.key } : {}),
|
|
1144
1954
|
}
|
|
1145
1955
|
const owner = this.sessionTenant(rootID)
|
|
1146
1956
|
for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
|