@huynle/hub 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +326 -1
  2. package/README.md +85 -16
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/paths.ts +3 -3
  6. package/packages/cli/dist/hub-cli.js +723 -281
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  9. package/packages/cli/src/artifacts-mcp.ts +8 -7
  10. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  11. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  12. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  13. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  14. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  15. package/packages/cli/src/cli.ts +3 -3
  16. package/packages/cli/src/http.ts +1 -0
  17. package/packages/cli/src/launches.ts +9 -3
  18. package/packages/cli/src/login.ts +2 -2
  19. package/packages/cli/src/mcp.ts +9 -4
  20. package/packages/cli/src/messaging.ts +4 -2
  21. package/packages/cli/src/profiles-mcp.ts +301 -0
  22. package/packages/cli/src/setup/commands.ts +20 -2
  23. package/packages/cli/src/spoke-services.ts +1 -1
  24. package/packages/cli/src/team-intake-mcp.ts +8 -7
  25. package/packages/cli/src/teams-mcp.ts +61 -3
  26. package/packages/cli/src/tool-args.ts +6 -0
  27. package/packages/cli/src/watchlist.ts +5 -4
  28. package/packages/codex/package.json +1 -1
  29. package/packages/codex/src/paths.ts +3 -3
  30. package/packages/node/package.json +1 -1
  31. package/packages/node/src/config.ts +10 -2
  32. package/packages/node/src/opencode-warm.ts +27 -8
  33. package/packages/node/src/run.ts +3 -2
  34. package/packages/preset/package.json +1 -1
  35. package/packages/protocol/package.json +1 -1
  36. package/packages/protocol/src/concierge.ts +4 -1
  37. package/packages/protocol/src/embedded-media.ts +59 -0
  38. package/packages/protocol/src/index.ts +90 -7
  39. package/packages/protocol/src/launch.ts +14 -0
  40. package/packages/protocol/src/wire.ts +1 -0
  41. package/packages/runtime-openshell/package.json +1 -1
  42. package/packages/runtime-sdk/package.json +1 -1
  43. package/packages/sdk/package.json +1 -1
  44. package/packages/sdk/src/client.ts +3 -1
  45. package/packages/sdk/src/errors.ts +1 -0
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/types.ts +22 -2
  48. package/packages/sdk/src/wake.ts +20 -0
  49. package/packages/spoke-sdk/package.json +1 -1
  50. package/packages/spoke-sdk/src/connection.ts +13 -1
  51. package/src/central/agent/acting-routes.ts +3 -2
  52. package/src/central/agent/acting.ts +70 -12
  53. package/src/central/agent/audit.ts +1 -1
  54. package/src/central/agent/decisions.ts +58 -2
  55. package/src/central/agent/delivery.ts +111 -28
  56. package/src/central/agent/errors.ts +2 -0
  57. package/src/central/agent/escalation.ts +39 -10
  58. package/src/central/agent/events.ts +7 -1
  59. package/src/central/agent/launch-engine.ts +173 -30
  60. package/src/central/agent/launch-routes.ts +20 -9
  61. package/src/central/agent/launch-spec.ts +60 -17
  62. package/src/central/agent/launch-store.ts +55 -2
  63. package/src/central/agent/message-limits.ts +89 -11
  64. package/src/central/agent/message-routes.ts +8 -3
  65. package/src/central/agent/messages.ts +3 -2
  66. package/src/central/agent/placement.ts +93 -18
  67. package/src/central/agent/read-routes.ts +3 -0
  68. package/src/central/agent/routes.ts +11 -2
  69. package/src/central/agent/webhooks.ts +17 -2
  70. package/src/central/artifacts/jobs-routes.ts +4 -1
  71. package/src/central/concierge/board-routes.ts +13 -2
  72. package/src/central/concierge/context.ts +3 -1
  73. package/src/central/concierge/inbound.ts +4 -7
  74. package/src/central/concierge/prompt.ts +10 -6
  75. package/src/central/concierge/proposals.ts +8 -2
  76. package/src/central/concierge/routes.ts +62 -2
  77. package/src/central/concierge/session-routes.ts +62 -6
  78. package/src/central/concierge/supervisor.ts +34 -7
  79. package/src/central/concierge/watchlist-routes.ts +4 -2
  80. package/src/central/config.ts +21 -5
  81. package/src/central/http.ts +87 -35
  82. package/src/central/main.ts +1 -1
  83. package/src/central/registry.ts +49 -4
  84. package/src/central/revert-windows.ts +66 -0
  85. package/src/central/spoke-ws.ts +6 -3
  86. package/src/central/teams/intake.ts +20 -6
  87. package/src/central/teams/staffing.ts +24 -2
  88. package/src/central/workspaces/sidechats-routes.ts +1 -1
  89. package/src/index.ts +63 -30
  90. package/src/migrate.ts +109 -3
  91. package/src/spoke/deliver.ts +6 -0
  92. package/src/spoke/env-inject.ts +8 -1
  93. package/src/spoke/fork-revert.ts +94 -9
  94. package/src/spoke/hub-tool.ts +26 -12
  95. package/src/spoke/launchers.ts +6 -4
  96. package/src/spoke/listing.ts +18 -6
  97. package/src/spoke/localhub.ts +14 -4
  98. package/src/spoke/locations.ts +120 -0
  99. package/src/spoke/profile-apply.ts +24 -14
  100. package/src/spoke/session-context.ts +183 -33
  101. package/src/spoke/spoke.ts +129 -41
  102. package/src/spoke/timeline.ts +30 -3
  103. package/src/spoke/tracker.ts +17 -1
  104. package/src/status.ts +2 -1
  105. package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
  106. package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
  107. package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
  108. package/web/dist/assets/Library-BXbJvTUS.js +2 -0
  109. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
  110. package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
  111. package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
  112. package/web/dist/assets/index-BabrNDB3.css +1 -0
  113. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  114. package/web/dist/assets/live-OgQWPrSY.js +5 -0
  115. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  116. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
  117. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
  118. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
  119. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  120. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  121. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  122. package/web/dist/index.html +4 -4
  123. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  124. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  125. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  126. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  127. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  128. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  129. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  130. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  131. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  132. package/web/dist/assets/live-YyTjE47g.js +0 -5
  133. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -16,12 +16,12 @@
16
16
  // origin only. Logs on changes only.
17
17
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync, type Stats } from "node:fs"
18
18
  import path from "node:path"
19
- import { readFile } from "node:fs/promises"
20
19
  import { listServiceSessions, type ListingClient, type RawProject, type RawSession } from "../../../src/spoke/listing.ts"
21
20
 
22
21
  /** How often the daemon asks (a restarted server is warmed within this). */
23
22
  export const WARM_INTERVAL_MS = 30_000
24
- const WARM_TIMEOUT_MS = 10_000
23
+ /** Per-request timeout (node.json opencode.timeoutSeconds overrides it): long enough for a busy machine's first project load. */
24
+ export const WARM_TIMEOUT_MS = 30_000
25
25
  /** Project locations warmed per server and tick, besides its default location. */
26
26
  export const WARM_DIRECTORIES_MAX = 8
27
27
  /** Automatic project locations: sessions updated within this many days, from one page of this many. */
@@ -30,6 +30,8 @@ const SESSION_PAGE = 100
30
30
  /** Loopback hosts asked as they are (after URL normalization); localhost is rewritten to 127.0.0.1. */
31
31
  const LOOPBACK = new Set(["127.0.0.1", "[::1]"])
32
32
  const PASSWORD_FILE_MAX = 4096
33
+ /** Largest service.json read (OpenCode's is a few hundred bytes). */
34
+ const REGISTRATION_MAX = 64 * 1024
33
35
 
34
36
  /** A local OpenCode server (the service's registration, or a node.json server with its password). */
35
37
  export interface WarmEndpoint {
@@ -243,23 +245,40 @@ export class OpenCodeWarmer {
243
245
 
244
246
  /**
245
247
  * OpenCode's background service from its registration file (service.json: {url, pid, password?, id?, version?}),
246
- * without any request: undefined when the file is missing or malformed or its process is gone. The URL is passed on
247
- * as it is; OpenCodeWarmer.tick decides whether it may be asked.
248
+ * without any request: undefined when the file is missing or malformed, is not this user's own (a symbolic link, not
249
+ * owned by this uid, or writable by group or others), or its process is gone or another user's (EPERM). The URL is
250
+ * passed on as it is; OpenCodeWarmer.tick decides whether it may be asked.
248
251
  */
249
- export async function readServiceRegistration(file: string): Promise<WarmEndpoint | undefined> {
252
+ export async function readServiceRegistration(file: string, options: { readonly uid?: number } = {}): Promise<WarmEndpoint | undefined> {
253
+ const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined)
250
254
  let info: unknown
255
+ let fd: number | undefined
251
256
  try {
252
- info = JSON.parse(await readFile(file, "utf8"))
257
+ const before = lstatSync(file)
258
+ if (!before.isFile()) return undefined
259
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
260
+ const stat = fstatSync(fd)
261
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return undefined
262
+ if ((stat.mode & 0o022) !== 0) return undefined
263
+ if (uid !== undefined && stat.uid !== uid) return undefined
264
+ if (stat.size > REGISTRATION_MAX) return undefined
265
+ // Read through the descriptor that was checked (no second open by path).
266
+ const buffer = Buffer.alloc(REGISTRATION_MAX + 1)
267
+ const length = readSync(fd, buffer, 0, buffer.length, 0)
268
+ if (length > REGISTRATION_MAX) return undefined
269
+ info = JSON.parse(buffer.subarray(0, length).toString("utf8"))
253
270
  } catch {
254
271
  return undefined
272
+ } finally {
273
+ if (fd !== undefined) closeSync(fd)
255
274
  }
256
275
  if (!info || typeof info !== "object") return undefined
257
276
  const { url, pid, password } = info as Record<string, unknown>
258
277
  if (typeof url !== "string" || url.length > 2048 || typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return undefined
259
278
  try {
260
279
  process.kill(pid, 0)
261
- } catch (cause) {
262
- if ((cause as NodeJS.ErrnoException).code !== "EPERM") return undefined
280
+ } catch {
281
+ return undefined // gone (ESRCH), or another user's process (EPERM): not this user's service
263
282
  }
264
283
  return typeof password === "string" ? { url, auth: { type: "basic", username: "opencode", password } } : { url }
265
284
  }
@@ -50,9 +50,10 @@ export const openCodeServiceFile = (env: Env, home: string): string =>
50
50
  function startOpenCodeWarmers(config: NodeConfig, service: () => Promise<WarmEndpoint | undefined>, log: (line: string) => void, signal: AbortSignal): void {
51
51
  const roots = () => config.workspaceRoots
52
52
  const directories = config.opencode?.directories
53
- new OpenCodeWarmer({ discover: service, log, roots, ...(directories ? { directories } : {}) }).start(signal)
53
+ const timeout = config.opencode?.timeoutSeconds !== undefined ? { timeoutMs: config.opencode.timeoutSeconds * 1000 } : {}
54
+ new OpenCodeWarmer({ discover: service, log, roots, ...timeout, ...(directories ? { directories } : {}) }).start(signal)
54
55
  for (const server of config.opencode?.servers ?? []) {
55
- new OpenCodeWarmer({ discover: serverEndpoint(server), log, name: "the OpenCode server", roots, ...(server.directories ? { directories: server.directories } : {}) }).start(signal)
56
+ new OpenCodeWarmer({ discover: serverEndpoint(server), log, name: "the OpenCode server", roots, ...timeout, ...(server.directories ? { directories: server.directories } : {}) }).start(signal)
56
57
  }
57
58
  }
58
59
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-preset",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": true,
5
5
  "description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-protocol",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": true,
5
5
  "description": "Hub ↔ spoke wire protocol (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -98,7 +98,10 @@ export const CONCIERGE_LIMITS = {
98
98
 
99
99
  // ---- Session context (CON-04, spoke feature "sessionContext") ----
100
100
 
101
- /** The keys of the parts a session context carries, appended to the session's system prompt in this order. */
101
+ /**
102
+ * The keys of the parts a session context carries. The spoke appends instructions then notes to the session's system
103
+ * prompt (stable between turns) and sends the board after the conversation (it changes: V-01, the prompt cache).
104
+ */
102
105
  export const SESSION_CONTEXT_KEYS = ["instructions", "board", "notes"] as const
103
106
  export type SessionContextKey = (typeof SESSION_CONTEXT_KEYS)[number]
104
107
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * (M1) Embedded media in tool-call details. Harnesses keep tool screenshots inside the tool output: OpenCode 2.2.3 as
3
+ * `data:` URLs (`read` of a PNG, MCP image results), MCP and Claude Code as raw base64 image parts. A tool-call
4
+ * detail shows none of these bytes: each is replaced by a short placeholder naming its type and approximate size,
5
+ * e.g. "[image/png, 1.2 MB — not shown]", so the rest of the detail (input, text output) fits the detail limit and
6
+ * no image bytes travel to the console. Inline media display is a separate, later feature.
7
+ */
8
+
9
+ /** A base64 `data:` URL: optional mime, optional parameters, then the payload. */
10
+ const DATA_URL = /data:([\w.+-]+\/[\w.+-]+)?((?:;[\w.+-]+=[^;,\s"'<>]*)*);base64,([A-Za-z0-9+/_-]+={0,2})/gi
11
+ const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/
12
+ const MIME_KEYS = ["mime", "mimeType", "media_type", "mediaType"] as const
13
+
14
+ /** The approximate decoded size of `length` base64 characters, for people ("10 B", "900 KB", "1.2 MB"). */
15
+ function sizeOf(base64: string): string {
16
+ const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0
17
+ const bytes = Math.max(0, Math.floor((base64.length * 3) / 4) - padding)
18
+ if (bytes < 1024) return `${bytes} B`
19
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
20
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
21
+ }
22
+
23
+ /** The placeholder shown instead of embedded bytes. */
24
+ export function mediaPlaceholder(mime: string | undefined, base64: string): string {
25
+ return `[${mime || "data"}, ${sizeOf(base64)} — not shown]`
26
+ }
27
+
28
+ function redactText(text: string): string {
29
+ if (!text.includes("base64,")) return text
30
+ return text.replace(DATA_URL, (_match, mime: string | undefined, _params: string, data: string) => mediaPlaceholder(mime?.toLowerCase(), data))
31
+ }
32
+
33
+ /** The mime of an object that carries raw base64 image bytes in `data`, else undefined. */
34
+ function rawImageMime(object: Record<string, unknown>): string | undefined {
35
+ if (typeof object.data !== "string" || !BASE64.test(object.data)) return undefined
36
+ const mime = MIME_KEYS.map((key) => object[key]).find((value): value is string => typeof value === "string")
37
+ if (mime?.toLowerCase().startsWith("image/")) return mime.toLowerCase()
38
+ if (object.type === "image" || object.type === "base64") return mime || "image"
39
+ return undefined
40
+ }
41
+
42
+ /**
43
+ * A copy of `value` (JSON-like) with every embedded base64 payload replaced by a placeholder: `data:…;base64,…` URLs
44
+ * in any string, and raw base64 `data` of image parts. Everything else is kept as it is; `value` is not changed.
45
+ */
46
+ export function redactEmbeddedMedia<T>(value: T): T {
47
+ return redact(value) as T
48
+ }
49
+
50
+ function redact(value: unknown): unknown {
51
+ if (typeof value === "string") return redactText(value)
52
+ if (Array.isArray(value)) return value.map(redact)
53
+ if (!value || typeof value !== "object") return value
54
+ const object = value as Record<string, unknown>
55
+ const mime = rawImageMime(object)
56
+ const out: Record<string, unknown> = {}
57
+ for (const [key, item] of Object.entries(object)) out[key] = key === "data" && mime ? mediaPlaceholder(mime, item as string) : redact(item)
58
+ return out
59
+ }
@@ -31,6 +31,7 @@ export * from "./hub-workspace.ts"
31
31
  export * from "./concierge.ts"
32
32
  export * from "./profiles.ts"
33
33
  export * from "./artifacts.ts"
34
+ export * from "./embedded-media.ts"
34
35
  export * from "./teams.ts"
35
36
  export * from "./graph.ts"
36
37
  export { HUB_URL_MAX_CHARS, isHubURL, MAX_FRAME, MAX_RETRY_AFTER_MS }
@@ -93,7 +94,8 @@ export interface AvailableModel {
93
94
  * A timeline page. Forward mode (`cursor`): oldest rows first, `cursor` for the next newer page. Newest-first
94
95
  * mode (`order: "desc"` or `before`): the newest rows (before `before`) in chronological order, `older` for the
95
96
  * page before them. Both cursors are opaque. Items are the spoke's rows (src/spoke/timeline.ts TimelineItem); newer
96
- * spokes add optional `pos` (ordering position), `callID`, `childSessionID`, `title` and `ordinal`.
97
+ * spokes add optional `pos` (ordering position), `callID`, `childSessionID`, `title`, `ordinal` and, on error rows,
98
+ * `declined` (a denied permission stopped the turn).
97
99
  */
98
100
  export interface TimelinePage {
99
101
  readonly items: readonly unknown[]
@@ -154,6 +156,14 @@ export interface SessionInfo {
154
156
  * Older hubs ignore it.
155
157
  */
156
158
  readonly revert?: SessionRevertInfo
159
+ /**
160
+ * The latest revert committed in this session as this process saw it (feature "revert"): the history from
161
+ * `messageID` on was removed. Hub message rows (agent messages the session sent or got) from `from` (the reverted
162
+ * message's time, when known) until `until` (when the revert was staged; later ones were held for after it) belonged
163
+ * to that history: the hub keeps the window and leaves them out. `at` (commit time) changes with every commit.
164
+ * Older hubs ignore it.
165
+ */
166
+ readonly revertCommitted?: RevertCommitted
157
167
  /**
158
168
  * (CON-04, feature "sessionContext") Sessions the hub gave a locked context (the Concierge): the agent the spoke
159
169
  * last saw the session run on, and the context revision it holds now (absent: none, e.g. after a restart). The hub
@@ -161,6 +171,11 @@ export interface SessionInfo {
161
171
  */
162
172
  readonly agent?: string
163
173
  readonly contextRevision?: number
174
+ /**
175
+ * (FX-07) The session is locked as a Concierge session on this spoke (sent from the first sessions frame after a
176
+ * restart, before `agent` or `contextRevision` are known). The hub treats it as a Concierge session.
177
+ */
178
+ readonly locked?: true
164
179
  }
165
180
 
166
181
  /** Most files a revert summary or preview lists (`filesTotal` counts them all). */
@@ -187,6 +202,21 @@ export interface SessionRevertInfo {
187
202
  /** Restored files (at most REVERT_FILES_MAX). */
188
203
  readonly files: readonly RevertFile[]
189
204
  readonly filesTotal: number
205
+ /** When this process saw it staged (epoch ms; optional, older spokes send none). */
206
+ readonly stagedAt?: number
207
+ }
208
+
209
+ /** A committed revert's removed window (SessionInfo.revertCommitted). */
210
+ export interface RevertCommitted {
211
+ readonly messageID: string
212
+ readonly from?: number
213
+ readonly until: number
214
+ readonly at: number
215
+ }
216
+
217
+ /** A committed revert's window as a spoke reports it. */
218
+ export function isRevertCommitted(v: unknown): v is RevertCommitted {
219
+ return isObj(v) && nonEmpty(v.messageID) && v.messageID.length <= 300 && (v.from === undefined || num(v.from)) && num(v.until) && num(v.at)
190
220
  }
191
221
 
192
222
  /** An attachment of a user message, by its position in the message's files (fetched through timelineFile). */
@@ -824,8 +854,29 @@ export interface Res {
824
854
  readonly ok: boolean
825
855
  readonly result?: unknown
826
856
  readonly error?: string
857
+ /**
858
+ * A refusal's kind, so the hub need not match `error` texts (optional and additive: older hubs ignore it, older
859
+ * spokes send none). See RES_ERROR_CODES; a peer may send codes the other does not know (they are ignored).
860
+ */
861
+ readonly code?: string
827
862
  }
828
863
 
864
+ /**
865
+ * Refusal codes a spoke puts on `{ok: false}` answers: "busy" (the session runs, e.g. a revert while a turn is
866
+ * running; the hub answers 409 busy) and "not_found" (the session, or the message an op names, is not there; 404).
867
+ */
868
+ export const RES_ERROR_CODES = ["busy", "not_found"] as const
869
+ export type ResErrorCode = (typeof RES_ERROR_CODES)[number]
870
+ /** Longest `code` a res frame carries. */
871
+ export const RES_CODE_MAX = 40
872
+
873
+ /** The spoke's refusal of a revert while the session runs (code "busy"). */
874
+ export const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
875
+ /** The spoke's refusal of a restore or commit while the session runs (code "busy"). */
876
+ export const REVERT_END_BUSY = "The session is working; wait for it to finish (or stop it) before restoring or committing the revert"
877
+ /** A fork or revert naming a message that is not one of the session's user messages (code "not_found"). */
878
+ export const NOT_A_USER_MESSAGE = "Not a user message of this session"
879
+
829
880
  export interface Bye {
830
881
  readonly t: "bye"
831
882
  readonly reason: string
@@ -910,8 +961,10 @@ function isSession(v: unknown): v is SessionInfo {
910
961
  (v.open === undefined || typeof v.open === "boolean") &&
911
962
  (v.terminal === undefined || isTerminal(v.terminal)) &&
912
963
  (v.revert === undefined || isSessionRevert(v.revert)) &&
964
+ (v.revertCommitted === undefined || isRevertCommitted(v.revertCommitted)) &&
913
965
  (v.agent === undefined || (nonEmpty(v.agent) && v.agent.length <= AGENT_NAME_MAX)) &&
914
- (v.contextRevision === undefined || (Number.isSafeInteger(v.contextRevision) && (v.contextRevision as number) >= 0))
966
+ (v.contextRevision === undefined || (Number.isSafeInteger(v.contextRevision) && (v.contextRevision as number) >= 0)) &&
967
+ (v.locked === undefined || v.locked === true)
915
968
  )
916
969
  }
917
970
 
@@ -942,7 +995,8 @@ export function isSessionRevert(v: unknown): v is SessionRevertInfo {
942
995
  v.files.length <= REVERT_FILES_MAX &&
943
996
  v.files.every(isRevertFile) &&
944
997
  count(v.filesTotal) &&
945
- (v.filesTotal as number) >= v.files.length
998
+ (v.filesTotal as number) >= v.files.length &&
999
+ (v.stagedAt === undefined || num(v.stagedAt))
946
1000
  )
947
1001
  }
948
1002
 
@@ -1181,8 +1235,11 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
1181
1235
  (m.launchID === undefined || (str(m.launchID) && ID.test(m.launchID)))
1182
1236
  ? (m as unknown as Hello)
1183
1237
  : undefined
1184
- case "sessions":
1185
- return Array.isArray(m.sessions) && m.sessions.every(isSession) ? (m as unknown as Sessions) : undefined
1238
+ case "sessions": {
1239
+ if (!Array.isArray(m.sessions)) return undefined
1240
+ const sessions = m.sessions.map(lenientSession)
1241
+ return sessions.every(isSession) ? ({ ...m, sessions } as unknown as Sessions) : undefined
1242
+ }
1186
1243
  case "terminals":
1187
1244
  return m.herdr === undefined || isHerdrInfo(m.herdr) ? (m as unknown as Terminals) : undefined
1188
1245
  case "event":
@@ -1192,7 +1249,7 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
1192
1249
  case "req":
1193
1250
  return nonEmpty(m.id) && isSpokeRequest(m.req) ? (m as unknown as Req<SpokeRequest>) : undefined
1194
1251
  case "res":
1195
- return isRes(m) ? (m as unknown as Res) : undefined
1252
+ return isRes(m) ? lenientRes(m) : undefined
1196
1253
  case "bye":
1197
1254
  return isBye(m) ? (m as unknown as Bye) : undefined
1198
1255
  default:
@@ -1200,6 +1257,32 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
1200
1257
  }
1201
1258
  }
1202
1259
 
1260
+ /** A res frame without an invalid `code` (over-long, empty or not a string): ignored, as for older peers. */
1261
+ function lenientRes(m: Record<string, unknown>): Res {
1262
+ if (m.code === undefined || (nonEmpty(m.code) && m.code.length <= RES_CODE_MAX)) return m as unknown as Res
1263
+ const { code: _dropped, ...rest } = m
1264
+ return rest as unknown as Res
1265
+ }
1266
+
1267
+ /**
1268
+ * A reported session without malformed additive revert fields (`revertCommitted`, `revert.stagedAt`): they are
1269
+ * dropped (older hubs ignore them too), never failing the whole sessions frame.
1270
+ */
1271
+ function lenientSession(v: unknown): unknown {
1272
+ if (!isObj(v)) return v
1273
+ let out: Record<string, unknown> = v
1274
+ if (out.revertCommitted !== undefined && !isRevertCommitted(out.revertCommitted)) {
1275
+ const { revertCommitted: _dropped, ...rest } = out
1276
+ out = rest
1277
+ }
1278
+ const revert = out.revert
1279
+ if (isObj(revert) && revert.stagedAt !== undefined && !num(revert.stagedAt)) {
1280
+ const { stagedAt: _dropped, ...rest } = revert
1281
+ out = { ...out, revert: rest }
1282
+ }
1283
+ return out
1284
+ }
1285
+
1203
1286
  /** Parses a frame received by the spoke. Returns undefined for anything invalid. */
1204
1287
  export function parseServerMessage(raw: string | Buffer): ServerMessage | undefined {
1205
1288
  const m = parse(raw)
@@ -1210,7 +1293,7 @@ export function parseServerMessage(raw: string | Buffer): ServerMessage | undefi
1210
1293
  case "req":
1211
1294
  return nonEmpty(m.id) && isServerRequest(m.req) ? (m as unknown as Req<ServerRequest>) : undefined
1212
1295
  case "res":
1213
- return isRes(m) ? (m as unknown as Res) : undefined
1296
+ return isRes(m) ? lenientRes(m) : undefined
1214
1297
  case "bye":
1215
1298
  return isBye(m) ? (m as unknown as Bye) : undefined
1216
1299
  default:
@@ -103,6 +103,14 @@ export interface LaunchArtifacts {
103
103
  readonly dirty?: boolean
104
104
  }
105
105
 
106
+ /** Why a queued launch waits (FX-02): capacity (a fitting node is full), no_match (no node fits), active (the per-user cap). */
107
+ export interface LaunchWaitingInfo {
108
+ readonly wait: "capacity" | "no_match" | "active"
109
+ readonly reason: string
110
+ /** ms epoch: since when it has waited for this kind of reason. */
111
+ readonly since: number
112
+ }
113
+
106
114
  /** A launch as the API reports it. */
107
115
  export interface LaunchInfo {
108
116
  readonly id: string
@@ -118,6 +126,12 @@ export interface LaunchInfo {
118
126
  readonly name?: string
119
127
  readonly outcome?: LaunchOutcome
120
128
  readonly artifacts?: LaunchArtifacts
129
+ /** Only while queued: why it waits (`reason` is the same text). */
130
+ readonly waiting?: LaunchWaitingInfo
131
+ /** The launcher's label when it launched (an app token's label). */
132
+ readonly byLabel?: string
133
+ /** The app token that launched it was revoked: it still runs and counts toward the cap until stopped. */
134
+ readonly launcherRevoked?: boolean
121
135
  }
122
136
 
123
137
  // ---------- checks ----------
@@ -17,6 +17,7 @@ export const featureList = (v: unknown): v is string[] => Array.isArray(v) && v.
17
17
  /** Control (C0, C1), format (bidi, zero-width, BOM) and line/paragraph separator characters. */
18
18
  export const UNSAFE_CHARS = /[\p{Cc}\p{Cf}\u2028\u2029]/u
19
19
 
20
+ /** A res frame (an optional `code` is not checked here: an invalid one is dropped, never fatal; see parse). */
20
21
  export const isResFrame = (v: Record<string, unknown>): boolean => nonEmpty(v.id) && typeof v.ok === "boolean" && (v.error === undefined || str(v.error))
21
22
  /** Longest reconnect delay a bye's retryAfterMs may ask for (5 minutes). */
22
23
  export const MAX_RETRY_AFTER_MS = 300_000
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-openshell",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": true,
5
5
  "description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": true,
5
5
  "description": "The Runtime interface `hub node` runtimes implement (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": true,
5
5
  "description": "Typed client for the hub's /api/agent (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -320,6 +320,8 @@ export interface ActOptions extends CallOptions {
320
320
  export interface ApproveOptions extends ActOptions {
321
321
  /** Approve for good (OpenCode saves the rule); agents need a grant with allowAlways. */
322
322
  readonly always?: boolean
323
+ /** (R-03) The Concierge advises approving: only then does its escalation Decision recommend approve. */
324
+ readonly recommend?: boolean
323
325
  }
324
326
 
325
327
  export interface PromptOptions extends ActOptions {
@@ -688,7 +690,7 @@ export class HubClient {
688
690
 
689
691
  /** Approves a pending permission request in a session's tree. */
690
692
  approve(addr: string, requestID: string, options: ApproveOptions = {}): Promise<Approved> {
691
- const body = { requestID, ...(options.always ? { always: true } : {}), ...whyField(options) }
693
+ const body = { requestID, ...(options.always ? { always: true } : {}), ...(options.recommend ? { recommend: true } : {}), ...whyField(options) }
692
694
  return this.call("POST", `${AGENT}/sessions/${segment(addr)}/approve`, { body, ...signalOf(options) })
693
695
  }
694
696
 
@@ -21,6 +21,7 @@ export type ErrorCode =
21
21
  | "rate_limited"
22
22
  | "internal"
23
23
  | "offline"
24
+ | "busy"
24
25
  | (string & {})
25
26
 
26
27
  export interface Envelope {
@@ -11,5 +11,6 @@ export * from "./concierge.ts"
11
11
  export * from "./envelope.ts"
12
12
  export * from "./errors.ts"
13
13
  export * from "./sse.ts"
14
+ export * from "./wake.ts"
14
15
  export * from "./webhook.ts"
15
16
  export type * from "./types.ts"
@@ -269,8 +269,11 @@ export type MessageKind = "msg" | "ask" | "reply" | "decline"
269
269
  /** `sent` for msg/reply/decline; asks are open until answered, declined or expired. */
270
270
  export type MessageState = "sent" | "open" | "answered" | "declined" | "expired"
271
271
  /** How a message reached its recipient session. */
272
- /** (CON-10) "dropped": it was pending for a session that ended for good (a retired Concierge session); never delivered. */
273
- export type MessageDelivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped"
272
+ /**
273
+ * (CON-10) "dropped": it was pending for a session that ended for good (a retired Concierge session); never delivered.
274
+ * "expired": it was pending for 24 h (its recipient stayed offline or busy); never delivered, and its sender was told.
275
+ */
276
+ export type MessageDelivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped" | "expired"
274
277
 
275
278
  /** A stored agent message. */
276
279
  export interface AgentMessage {
@@ -296,10 +299,21 @@ export interface AgentMessage {
296
299
  readonly answer?: { readonly id: string; readonly at: number }
297
300
  }
298
301
 
302
+ /**
303
+ * Why a message to a session did not start a turn: the sender asked wake:false, the recipient is a subagent
304
+ * (never woken), the reply chain went past the hop cap (8 hops without a human turn), or the recipient's wake
305
+ * budget (30 per 10 minutes) is spent. The message is still delivered.
306
+ */
307
+ export type WakeSkip = "wake_false" | "subagent" | "hop_cap" | "budget"
308
+
299
309
  /** POST /api/agent/msg and /ask: the stored message and how it was delivered (none: stored only). */
300
310
  export interface Sent {
301
311
  readonly message: AgentMessage
302
312
  readonly delivery?: MessageDelivery
313
+ /** Messages to a session: whether the hub sent it to wake the recipient (absent from hubs before 0.8.1). */
314
+ readonly woke?: boolean
315
+ /** When woke is false: why. */
316
+ readonly wakeSkipped?: WakeSkip
303
317
  }
304
318
 
305
319
  /** POST /api/agent/msg or /ask with all: one per session. */
@@ -577,6 +591,12 @@ export interface LaunchView {
577
591
  readonly updated: number
578
592
  /** Who launched it: `user`, `app:<token id>` or a session ID. */
579
593
  readonly by?: string
594
+ /** The launcher's label when it launched (an app token's label). */
595
+ readonly byLabel?: string
596
+ /** The app token that launched it was revoked: it still runs and counts toward the cap until stopped. */
597
+ readonly launcherRevoked?: boolean
598
+ /** Only while queued: why it waits (`reason` is the same text). */
599
+ readonly waiting?: { readonly wait: "capacity" | "no_match" | "active"; readonly reason: string; readonly since: number }
580
600
  readonly machineID?: string
581
601
  readonly directory?: string
582
602
  readonly sessionID?: string
@@ -0,0 +1,20 @@
1
+ // Why a message did not wake its recipient (Sent.woke / Sent.wakeSkipped, hub 0.8.1), in the words the CLI, `hub mcp`
2
+ // and the OpenCode hub tool show: one place so every client says the same thing.
3
+ import type { Sent, WakeSkip } from "./types.ts"
4
+
5
+ /** One short reason per WakeSkip. */
6
+ export const WAKE_SKIP_TEXT: Readonly<Record<WakeSkip, string>> = {
7
+ wake_false: "sent without a wake",
8
+ subagent: "subagents are never woken",
9
+ hop_cap: "hop limit reached (8 agent messages without a human turn)",
10
+ budget: "wake budget spent (30 per 10 minutes)",
11
+ }
12
+
13
+ /**
14
+ * ` — wake skipped: <reason>` when the hub did not wake the recipient and the message waits for a later turn; empty
15
+ * when it woke, went into a running turn (steer) or started one, or the hub did not say (hubs before 0.8.1).
16
+ */
17
+ export function wakeSkipNote({ delivery, woke, wakeSkipped }: Pick<Sent, "delivery" | "woke" | "wakeSkipped">): string {
18
+ if (woke !== false || !wakeSkipped || delivery === "steer" || delivery === "turn") return ""
19
+ return ` — wake skipped: ${WAKE_SKIP_TEXT[wakeSkipped] ?? wakeSkipped}`
20
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": true,
5
5
  "description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@ import {
11
11
  type ServerRequest,
12
12
  type SpokeMessage,
13
13
  type SpokeRequest,
14
+ type ResErrorCode,
14
15
  type Welcome,
15
16
  } from "../../protocol/src/index.ts"
16
17
 
@@ -67,6 +68,17 @@ const defaultSocket = (url: string, headers: Record<string, string>): SocketLike
67
68
 
68
69
  const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
69
70
 
71
+ /** An op's refusal with a protocol code (Res.code, RES_ERROR_CODES): the hub maps it without matching the text. */
72
+ export class CodedError extends Error {
73
+ constructor(
74
+ message: string,
75
+ readonly code: ResErrorCode,
76
+ ) {
77
+ super(message)
78
+ this.name = "CodedError"
79
+ }
80
+ }
81
+
70
82
  interface Pending {
71
83
  resolve: (value: unknown) => void
72
84
  reject: (error: Error) => void
@@ -245,7 +257,7 @@ export class SpokeConnection {
245
257
  try {
246
258
  reply = { t: "res", id, ok: true, result: await this.options.handle(req) }
247
259
  } catch (cause) {
248
- reply = { t: "res", id, ok: false, error: message(cause) }
260
+ reply = { t: "res", id, ok: false, error: message(cause), ...(cause instanceof CodedError ? { code: cause.code } : {}) }
249
261
  }
250
262
  this.send(reply)
251
263
  }
@@ -1,6 +1,6 @@
1
1
  // The act verbs on /api/agent (design §5, §8; decision wvqeuis0): thin HTTP over the acting engine (acting.ts).
2
2
  //
3
- // POST /api/agent/sessions/:addr/approve {requestID, always?, why?}
3
+ // POST /api/agent/sessions/:addr/approve {requestID, always?, why?, recommend?} recommend: a Concierge advises it (R-03)
4
4
  // POST /api/agent/sessions/:addr/deny {requestID, why?} why → the harness, with who denied it
5
5
  // POST /api/agent/sessions/:addr/answer {formID, answer | cancel: true, why?}
6
6
  // POST /api/agent/sessions/:addr/prompt {text, images? (users), delivery? "queue"|"steer", wake?, why?}
@@ -65,7 +65,8 @@ export function actingRoutes(acting: Acting): AgentRoute[] {
65
65
  async handle({ request, params, actor, user }) {
66
66
  const body = await bodyOf(request)
67
67
  const always = flag(body, "always")
68
- return ok(await acting.approve({ actor, userID: user.id }, params.addr!, { requestID: id(body, "requestID"), ...(always ? { always } : {}), ...why(body) }))
68
+ const recommend = flag(body, "recommend")
69
+ return ok(await acting.approve({ actor, userID: user.id }, params.addr!, { requestID: id(body, "requestID"), ...(always ? { always } : {}), ...(recommend ? { recommend } : {}), ...why(body) }))
69
70
  },
70
71
  },
71
72
  {