@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.
- package/CHANGELOG.md +326 -1
- package/README.md +85 -16
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +723 -281
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +9 -4
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +61 -3
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +90 -7
- package/packages/protocol/src/launch.ts +14 -0
- package/packages/protocol/src/wire.ts +1 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +3 -1
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +70 -12
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +111 -28
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +39 -10
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +60 -17
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +11 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +10 -6
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +87 -35
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +49 -4
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +6 -3
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +24 -2
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +63 -30
- package/src/migrate.ts +109 -3
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +26 -12
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +120 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +183 -33
- package/src/spoke/spoke.ts +129 -41
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +17 -1
- package/src/status.ts +2 -1
- package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
- package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
- package/web/dist/assets/Library-BXbJvTUS.js +2 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
- package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
- package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-g4V9mfSr.js +34 -0
- package/web/dist/assets/live-OgQWPrSY.js +5 -0
- package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- 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
|
-
|
|
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
|
|
247
|
-
*
|
|
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
|
-
|
|
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
|
|
262
|
-
|
|
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
|
}
|
package/packages/node/src/run.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|
|
@@ -98,7 +98,10 @@ export const CONCIERGE_LIMITS = {
|
|
|
98
98
|
|
|
99
99
|
// ---- Session context (CON-04, spoke feature "sessionContext") ----
|
|
100
100
|
|
|
101
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|
|
@@ -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
|
|
|
@@ -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
|
-
/**
|
|
273
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
{
|