@huynle/hub 0.8.1 → 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 +113 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +87 -52
- 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/mcp.ts +4 -3
- package/packages/cli/src/profiles-mcp.ts +3 -3
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +2 -1
- 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/node/package.json +1 -1
- 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 +83 -6
- 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/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 +67 -12
- package/src/central/agent/delivery.ts +12 -3
- package/src/central/agent/escalation.ts +30 -9
- package/src/central/agent/launch-spec.ts +38 -19
- package/src/central/agent/routes.ts +7 -0
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/prompt.ts +7 -4
- package/src/central/http.ts +50 -21
- package/src/central/registry.ts +23 -3
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +6 -3
- package/src/central/teams/staffing.ts +2 -2
- package/src/index.ts +26 -14
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +2 -2
- package/src/spoke/locations.ts +80 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +76 -6
- package/src/spoke/spoke.ts +116 -36
- 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-mNzjlP_U.js → ArtifactPage-R0ZTpHca.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-C9KrBna8.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-sP2mZccC.js} +1 -1
- package/web/dist/assets/{Library-LXHsP5Fw.js → Library-BXbJvTUS.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DhVVcAep.js → ProfilesDestination-D7XyLTJ7.js} +1 -1
- package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-Dgxt8UI3.js} +1 -1
- package/web/dist/assets/{Watchtower-DxaJYQp_.js → Watchtower-DJHx6cWw.js} +2 -2
- package/web/dist/assets/index-g4V9mfSr.js +34 -0
- package/web/dist/assets/{live-BDq0AABg.js → live-OgQWPrSY.js} +1 -1
- package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-DjEipyto.js} +1 -1
- package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-B648jKGO.js} +1 -1
- package/web/dist/assets/{safe-area-JUb6OeI3.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/index.html +1 -1
- package/web/dist/assets/index-lIOfSYck.js +0 -34
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// (+ artifacts:read) for an app token. Content never travels in requests or job listings, only in the context.
|
|
8
8
|
import type { ArtifactJob, ArtifactJobContext, ArtifactJobKind, ArtifactJobRequest } from "../../sdk/src/artifact-jobs.ts"
|
|
9
9
|
import type { Artifacts } from "../../sdk/src/artifacts.ts"
|
|
10
|
+
import { ToolArgError } from "./tool-args"
|
|
10
11
|
|
|
11
12
|
/** UTF-8 bytes of the inline proposal text one tool call may carry. */
|
|
12
13
|
export const MCP_JOB_TEXT_BYTES = 256 * 1024
|
|
@@ -33,27 +34,27 @@ const KINDS: readonly ArtifactJobKind[] = ["ask", "propose-edit", "run-action"]
|
|
|
33
34
|
|
|
34
35
|
const need = (args: Args, key: string): string => {
|
|
35
36
|
const value = args[key]
|
|
36
|
-
if (typeof value !== "string" || !value.trim()) throw new
|
|
37
|
+
if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
|
|
37
38
|
return value
|
|
38
39
|
}
|
|
39
40
|
const optional = (args: Args, key: string): string | undefined => {
|
|
40
41
|
const value = args[key]
|
|
41
42
|
if (value === undefined || value === null) return undefined
|
|
42
|
-
if (typeof value !== "string") throw new
|
|
43
|
+
if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
|
|
43
44
|
return value.trim() ? value : undefined
|
|
44
45
|
}
|
|
45
46
|
const integer = (args: Args, key: string): number => {
|
|
46
47
|
const value = args[key]
|
|
47
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new
|
|
48
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new ToolArgError(`"${key}" is required (a whole number)`)
|
|
48
49
|
return value
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function handlerOf(args: Args): ArtifactJobRequest["handler"] {
|
|
52
53
|
const session = optional(args, "session")
|
|
53
54
|
const machine = optional(args, "machine")
|
|
54
|
-
if (session && machine) throw new
|
|
55
|
+
if (session && machine) throw new ToolArgError('give either "session" (an existing session) or "machine" + "repo" (a new handler session), not both')
|
|
55
56
|
if (session) return { session }
|
|
56
|
-
if (!machine) throw new
|
|
57
|
+
if (!machine) throw new ToolArgError('"machine" and "repo" are required to launch a handler session (or name an existing one with "session")')
|
|
57
58
|
const repo = need(args, "repo")
|
|
58
59
|
const harness = optional(args, "harness")
|
|
59
60
|
const model = optional(args, "model")
|
|
@@ -114,7 +115,7 @@ export const artifactJobTools: readonly ArtifactJobTool[] = [
|
|
|
114
115
|
readOnly: false,
|
|
115
116
|
run: async (hub, args, signal) => {
|
|
116
117
|
const kind = optional(args, "kind") as ArtifactJobKind | undefined
|
|
117
|
-
if (kind && !KINDS.includes(kind)) throw new
|
|
118
|
+
if (kind && !KINDS.includes(kind)) throw new ToolArgError(`"kind" must be one of ${KINDS.join(", ")}`)
|
|
118
119
|
const action = optional(args, "action")
|
|
119
120
|
const apply = optional(args, "apply") as "manual" | "auto" | undefined
|
|
120
121
|
const key = optional(args, "key")
|
|
@@ -181,10 +182,10 @@ export const artifactJobTools: readonly ArtifactJobTool[] = [
|
|
|
181
182
|
const error = optional(args, "error")
|
|
182
183
|
const answer = optional(args, "answer")
|
|
183
184
|
const text = args.text
|
|
184
|
-
if (text !== undefined && typeof text !== "string") throw new
|
|
185
|
-
if (typeof text === "string" && Buffer.byteLength(text, "utf8") > MCP_JOB_TEXT_BYTES) throw new
|
|
185
|
+
if (text !== undefined && typeof text !== "string") throw new ToolArgError('"text" must be a string')
|
|
186
|
+
if (typeof text === "string" && Buffer.byteLength(text, "utf8") > MCP_JOB_TEXT_BYTES) throw new ToolArgError(`"text" is larger than ${MCP_JOB_TEXT_BYTES} bytes`)
|
|
186
187
|
const keep = args.keep
|
|
187
|
-
if (keep !== undefined && (!Array.isArray(keep) || !keep.every((k) => typeof k === "string"))) throw new
|
|
188
|
+
if (keep !== undefined && (!Array.isArray(keep) || !keep.every((k) => typeof k === "string"))) throw new ToolArgError('"keep" must be a list of paths')
|
|
188
189
|
const path = optional(args, "path")
|
|
189
190
|
const entry = optional(args, "entry")
|
|
190
191
|
const result = error
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// tool ever reads local files). Needs scope artifacts:read / artifacts:write for an app token; sessions create and
|
|
6
6
|
// read workspace-shared artifacts only (revising stays with the user in v1).
|
|
7
7
|
import type { Artifact, ArtifactKind, ArtifactRevision, Artifacts, BlobRef } from "../../sdk/src/artifacts.ts"
|
|
8
|
+
import { ToolArgError } from "./tool-args"
|
|
8
9
|
|
|
9
10
|
/** UTF-8 bytes of inline text one tool call may carry (bigger content: stage it, then pass `blobs`). */
|
|
10
11
|
export const MCP_INLINE_TEXT_BYTES = 256 * 1024
|
|
@@ -45,24 +46,24 @@ const FILE_ARGS = {
|
|
|
45
46
|
|
|
46
47
|
const need = (args: Args, key: string): string => {
|
|
47
48
|
const value = args[key]
|
|
48
|
-
if (typeof value !== "string" || !value.trim()) throw new
|
|
49
|
+
if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
|
|
49
50
|
return value
|
|
50
51
|
}
|
|
51
52
|
const optional = (args: Args, key: string): string | undefined => {
|
|
52
53
|
const value = args[key]
|
|
53
54
|
if (value === undefined || value === null) return undefined
|
|
54
|
-
if (typeof value !== "string") throw new
|
|
55
|
+
if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
|
|
55
56
|
return value.trim() ? value : undefined
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/** The files of a publish/revise: inline text and/or blob references (one of them required). */
|
|
59
60
|
function files(args: Args, kind: ArtifactKind | undefined): { blobs?: BlobRef[]; upload?: { mediaType: string; data: string; path?: string }[]; entry?: string } {
|
|
60
61
|
const text = args.text
|
|
61
|
-
if (text !== undefined && typeof text !== "string") throw new
|
|
62
|
+
if (text !== undefined && typeof text !== "string") throw new ToolArgError('"text" must be a string')
|
|
62
63
|
const blobs = args.blobs
|
|
63
|
-
if (blobs !== undefined && (!Array.isArray(blobs) || !blobs.every((b) => typeof b === "object" && b !== null))) throw new
|
|
64
|
-
if (text === undefined && (blobs === undefined || blobs.length === 0)) throw new
|
|
65
|
-
if (text !== undefined && Buffer.byteLength(text, "utf8") > MCP_INLINE_TEXT_BYTES) throw new
|
|
64
|
+
if (blobs !== undefined && (!Array.isArray(blobs) || !blobs.every((b) => typeof b === "object" && b !== null))) throw new ToolArgError('"blobs" must be a list of blob references')
|
|
65
|
+
if (text === undefined && (blobs === undefined || blobs.length === 0)) throw new ToolArgError('give text or blobs (stage bigger content first and pass its reference)')
|
|
66
|
+
if (text !== undefined && Buffer.byteLength(text, "utf8") > MCP_INLINE_TEXT_BYTES) throw new ToolArgError(`text is larger than ${MCP_INLINE_TEXT_BYTES} bytes: stage it with uploadBlob and pass blobs`)
|
|
66
67
|
const path = optional(args, "path")
|
|
67
68
|
const entry = optional(args, "entry")
|
|
68
69
|
const mediaType = optional(args, "mediaType") ?? (kind && DEFAULT_MEDIA[kind]) ?? "text/plain"
|
|
@@ -126,7 +127,7 @@ export const artifactTools: readonly ArtifactTool[] = [
|
|
|
126
127
|
run: async (hub, args, signal) => {
|
|
127
128
|
const title = need(args, "title")
|
|
128
129
|
const kind = need(args, "kind") as ArtifactKind
|
|
129
|
-
if (!KINDS.includes(kind)) throw new
|
|
130
|
+
if (!KINDS.includes(kind)) throw new ToolArgError(`"kind" must be one of ${KINDS.join(", ")}`)
|
|
130
131
|
const body = files(args, kind)
|
|
131
132
|
const description = optional(args, "description")
|
|
132
133
|
const visibility = optional(args, "visibility") as "private" | "workspace" | undefined
|
package/packages/cli/src/mcp.ts
CHANGED
|
@@ -26,7 +26,8 @@ import { artifactTools } from "./artifacts-mcp"
|
|
|
26
26
|
import { artifactJobTools } from "./artifact-jobs-mcp"
|
|
27
27
|
import { teamIntakeTools } from "./team-intake-mcp"
|
|
28
28
|
import { teamTools } from "./teams-mcp"
|
|
29
|
-
import {
|
|
29
|
+
import { profileLifecycleTools } from "./profiles-mcp"
|
|
30
|
+
import { ToolArgError } from "./tool-args"
|
|
30
31
|
import { watchlistTools } from "./watchlist"
|
|
31
32
|
|
|
32
33
|
/** The protocol revisions the server speaks, newest first: a client's supported one is echoed, else the newest. */
|
|
@@ -48,7 +49,7 @@ type Id = string | number | null
|
|
|
48
49
|
type Args = Record<string, unknown>
|
|
49
50
|
|
|
50
51
|
/** A bad tool argument (an isError result, not a protocol error). */
|
|
51
|
-
|
|
52
|
+
const ArgError = ToolArgError
|
|
52
53
|
|
|
53
54
|
const need = (args: Args, key: string): string => {
|
|
54
55
|
const value = args[key]
|
|
@@ -1143,7 +1144,7 @@ export function createMcpServer(options: McpServerOptions): McpServer {
|
|
|
1143
1144
|
return controller.signal.aborted ? undefined : ok(id, toolResult(clean(text, token), false))
|
|
1144
1145
|
} catch (cause) {
|
|
1145
1146
|
if (controller.signal.aborted) return undefined // cancelled: no answer (MCP cancellation)
|
|
1146
|
-
const text = cause instanceof
|
|
1147
|
+
const text = cause instanceof ToolArgError ? `invalid: ${cause.message}` : cause instanceof CliError ? errorText(cause) : `${tool.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
1147
1148
|
return ok(id, toolResult(clean(text, token), true))
|
|
1148
1149
|
} finally {
|
|
1149
1150
|
if (inFlight.get(key(id)) === controller) inFlight.delete(key(id))
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// version can be restored with fromVersion); archive/restore (no new versions or pins) and pins (applied to a running
|
|
15
15
|
// session) keep the default destructiveHint. Changes send expectedRevision, so repeating one is idempotent.
|
|
16
16
|
import { sanitizeText } from "../../sdk/src/envelope.ts"
|
|
17
|
+
import { ToolArgError } from "./tool-args"
|
|
17
18
|
import type { Profiles } from "../../sdk/src/client.ts"
|
|
18
19
|
import type { ProfileCommand, ProfileContent, ProfileSkill, ProfileVersionSummary, ProfileView } from "../../sdk/src/types.ts"
|
|
19
20
|
|
|
@@ -53,9 +54,8 @@ const META_FIELDS = {
|
|
|
53
54
|
} as const
|
|
54
55
|
const SCOPES = "App tokens need scope profiles:read (reads) or profiles:manage (changes; profile_version_create with content fields and the skill/command set/remove tools read the latest version first, so they also need profiles:read); pins need profiles:assign and sessions:read (an app also needs a prompt grant on the session). Session tokens never change profiles. A profile confers no authority."
|
|
55
56
|
|
|
56
|
-
/** A bad tool argument: mcp.ts answers it as `invalid: …` (an isError result). */
|
|
57
|
-
|
|
58
|
-
const ArgError = ProfileArgError
|
|
57
|
+
/** A bad tool argument: mcp.ts answers it as `invalid: …` (an isError result; tool-args.ts). */
|
|
58
|
+
const ArgError = ToolArgError
|
|
59
59
|
|
|
60
60
|
const need = (args: Args, key: string): string => {
|
|
61
61
|
const value = args[key]
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// sessions submit under team policy and, in the team's entry role, report status.
|
|
6
6
|
import { sanitizeText } from "../../sdk/src/envelope.ts"
|
|
7
7
|
import { reportIntakeStatus, TEAM_INTAKE_STATUS_INPUTS, type TeamIntake, type TeamIntakeApi, type TeamIntakePriority, type TeamIntakeStatus, type TeamIntakeStatusInput } from "../../sdk/src/team-intake.ts"
|
|
8
|
+
import { ToolArgError } from "./tool-args"
|
|
8
9
|
|
|
9
10
|
type Args = Record<string, unknown>
|
|
10
11
|
|
|
@@ -29,24 +30,24 @@ const UPDATES: readonly TeamIntakeStatusInput[] = TEAM_INTAKE_STATUS_INPUTS
|
|
|
29
30
|
|
|
30
31
|
const need = (args: Args, key: string): string => {
|
|
31
32
|
const value = args[key]
|
|
32
|
-
if (typeof value !== "string" || !value.trim()) throw new
|
|
33
|
+
if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
|
|
33
34
|
return value
|
|
34
35
|
}
|
|
35
36
|
const optional = (args: Args, key: string): string | undefined => {
|
|
36
37
|
const value = args[key]
|
|
37
38
|
if (value === undefined || value === null) return undefined
|
|
38
|
-
if (typeof value !== "string") throw new
|
|
39
|
+
if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
|
|
39
40
|
return value.trim() ? value : undefined
|
|
40
41
|
}
|
|
41
42
|
const strings = (args: Args, key: string): string[] | undefined => {
|
|
42
43
|
const value = args[key]
|
|
43
44
|
if (value === undefined || value === null) return undefined
|
|
44
|
-
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new
|
|
45
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new ToolArgError(`"${key}" must be a list of strings`)
|
|
45
46
|
return value
|
|
46
47
|
}
|
|
47
48
|
const oneOf = <T extends string>(args: Args, key: string, allowed: readonly T[]): T | undefined => {
|
|
48
49
|
const value = optional(args, key)
|
|
49
|
-
if (value !== undefined && !allowed.includes(value as T)) throw new
|
|
50
|
+
if (value !== undefined && !allowed.includes(value as T)) throw new ToolArgError(`"${key}" must be one of ${allowed.join(", ")}`)
|
|
50
51
|
return value as T | undefined
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -55,7 +56,7 @@ function dueOf(args: Args): number | undefined {
|
|
|
55
56
|
const value = args.due
|
|
56
57
|
if (value === undefined || value === null || value === "") return undefined
|
|
57
58
|
const ms = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN
|
|
58
|
-
if (!Number.isSafeInteger(ms) || ms <= 0) throw new
|
|
59
|
+
if (!Number.isSafeInteger(ms) || ms <= 0) throw new ToolArgError(`"due" must be an ISO date/time or epoch milliseconds`)
|
|
59
60
|
return ms
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -146,10 +147,10 @@ export const teamIntakeTools: readonly TeamIntakeTool[] = [
|
|
|
146
147
|
idempotent: true,
|
|
147
148
|
run: async (hub, args, signal) => {
|
|
148
149
|
const status = oneOf(args, "status", UPDATES)
|
|
149
|
-
if (!status) throw new
|
|
150
|
+
if (!status) throw new ToolArgError(`"status" is required: one of ${UPDATES.join(", ")}`)
|
|
150
151
|
const expectedRevision = args.expectedRevision
|
|
151
152
|
if (expectedRevision !== undefined && expectedRevision !== null && (typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0))
|
|
152
|
-
throw new
|
|
153
|
+
throw new ToolArgError(`"expectedRevision" must be the revision you read (or left out)`)
|
|
153
154
|
const reason = optional(args, "reason")
|
|
154
155
|
const { intake, assignment } = await reportIntakeStatus(hub.teams, need(args, "team"), need(args, "intake"), { status, ...(typeof expectedRevision === "number" ? { expectedRevision } : {}), ...(reason ? { reason } : {}) }, { signal })
|
|
155
156
|
return `${intake.id} is ${intake.status}${intake.progress ? ` (${intake.progress})` : ""}, revision ${intake.revision}${assignment ? `; assignment ${assignment.id} ${assignment.status}` : ""}`
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// the default destructiveHint. Changes of existing records send expectedRevision, so repeating one is idempotent.
|
|
11
11
|
// Decision answers and grant administration stay console-only: there is no tool for them here.
|
|
12
12
|
import { sanitizeText } from "../../sdk/src/envelope.ts"
|
|
13
|
+
import { ToolArgError } from "./tool-args"
|
|
13
14
|
import type { LaunchSpec } from "../../sdk/src/types.ts"
|
|
14
15
|
import type { Team, TeamDraft, TeamEdge, TeamPatch, TeamRole, TeamRoster, Teams, TeamSpec } from "../../sdk/src/teams.ts"
|
|
15
16
|
|
|
@@ -116,7 +117,7 @@ const LAUNCH = {
|
|
|
116
117
|
} as const
|
|
117
118
|
const SCOPES = "App tokens need scope teams:read (reads) or teams:manage (changes); seats, status and staffing also need sessions:read. Session tokens read only teams they are seated in and never change definitions. Team roles confer no authority."
|
|
118
119
|
|
|
119
|
-
|
|
120
|
+
const ArgError = ToolArgError
|
|
120
121
|
|
|
121
122
|
const need = (args: Args, key: string): string => {
|
|
122
123
|
const value = args[key]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// (R-04) The one error class for a bad MCP tool argument. Every tool family (mcp.ts, teams-mcp.ts,
|
|
2
|
+
// team-intake-mcp.ts, artifacts-mcp.ts, artifact-jobs-mcp.ts, profiles-mcp.ts, watchlist.ts) throws it, and mcp.ts
|
|
3
|
+
// answers it as `invalid: …` (an isError result), never `<tool> failed: …`.
|
|
4
|
+
|
|
5
|
+
/** A bad tool argument: mcp.ts answers it as `invalid: <message>`. */
|
|
6
|
+
export class ToolArgError extends Error {}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// act token (show and the verbs). Other session tokens get not found (exit 3).
|
|
5
5
|
import { sanitizeLabel } from "../../sdk/src/envelope.ts"
|
|
6
6
|
import type { Concierge, WatchlistChanged, WatchlistConfig, WatchlistUpdate, WatchlistView } from "../../sdk/src/index.ts"
|
|
7
|
+
import { ToolArgError } from "./tool-args"
|
|
7
8
|
|
|
8
9
|
type Args = Record<string, unknown>
|
|
9
10
|
|
|
@@ -52,13 +53,13 @@ const STRINGS = { type: "array", items: { type: "string" } } as const
|
|
|
52
53
|
|
|
53
54
|
const need = (args: Args, key: string): string => {
|
|
54
55
|
const value = args[key]
|
|
55
|
-
if (typeof value !== "string" || !value.trim()) throw new
|
|
56
|
+
if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
|
|
56
57
|
return value
|
|
57
58
|
}
|
|
58
59
|
const flag = (args: Args, key: string, fallback: boolean): boolean => {
|
|
59
60
|
const value = args[key]
|
|
60
61
|
if (value === undefined || value === null) return fallback
|
|
61
|
-
if (typeof value !== "boolean") throw new
|
|
62
|
+
if (typeof value !== "boolean") throw new ToolArgError(`"${key}" must be true or false`)
|
|
62
63
|
return value
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -92,9 +93,9 @@ export const watchlistTools: readonly WatchlistTool[] = [
|
|
|
92
93
|
organizes: true,
|
|
93
94
|
run: async (hub, args, signal) => {
|
|
94
95
|
const expected = args.expectedRevision
|
|
95
|
-
if (typeof expected !== "number" || !Number.isSafeInteger(expected) || expected < 0) throw new
|
|
96
|
+
if (typeof expected !== "number" || !Number.isSafeInteger(expected) || expected < 0) throw new ToolArgError(`"expectedRevision" is required (the revision you read)`)
|
|
96
97
|
const unknown = Object.keys(args).filter((k) => k !== "expectedRevision" && !(UPDATE_FIELDS as readonly string[]).includes(k))
|
|
97
|
-
if (unknown.length) throw new
|
|
98
|
+
if (unknown.length) throw new ToolArgError(`Unknown argument${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${safe(k).slice(0, 40)}"`).join(", ")}: use ${UPDATE_FIELDS.join(", ")} and expectedRevision`)
|
|
98
99
|
const patch: Record<string, unknown> = {}
|
|
99
100
|
for (const field of UPDATE_FIELDS) if (args[field] !== undefined && args[field] !== null) patch[field] = args[field]
|
|
100
101
|
const r = await hub.concierge.watchlist.update(patch as WatchlistUpdate, expected, { signal })
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-codex",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-node",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-preset",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.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
|
|
@@ -192,6 +202,21 @@ export interface SessionRevertInfo {
|
|
|
192
202
|
/** Restored files (at most REVERT_FILES_MAX). */
|
|
193
203
|
readonly files: readonly RevertFile[]
|
|
194
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)
|
|
195
220
|
}
|
|
196
221
|
|
|
197
222
|
/** An attachment of a user message, by its position in the message's files (fetched through timelineFile). */
|
|
@@ -829,8 +854,29 @@ export interface Res {
|
|
|
829
854
|
readonly ok: boolean
|
|
830
855
|
readonly result?: unknown
|
|
831
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
|
|
832
862
|
}
|
|
833
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
|
+
|
|
834
880
|
export interface Bye {
|
|
835
881
|
readonly t: "bye"
|
|
836
882
|
readonly reason: string
|
|
@@ -915,6 +961,7 @@ function isSession(v: unknown): v is SessionInfo {
|
|
|
915
961
|
(v.open === undefined || typeof v.open === "boolean") &&
|
|
916
962
|
(v.terminal === undefined || isTerminal(v.terminal)) &&
|
|
917
963
|
(v.revert === undefined || isSessionRevert(v.revert)) &&
|
|
964
|
+
(v.revertCommitted === undefined || isRevertCommitted(v.revertCommitted)) &&
|
|
918
965
|
(v.agent === undefined || (nonEmpty(v.agent) && v.agent.length <= AGENT_NAME_MAX)) &&
|
|
919
966
|
(v.contextRevision === undefined || (Number.isSafeInteger(v.contextRevision) && (v.contextRevision as number) >= 0)) &&
|
|
920
967
|
(v.locked === undefined || v.locked === true)
|
|
@@ -948,7 +995,8 @@ export function isSessionRevert(v: unknown): v is SessionRevertInfo {
|
|
|
948
995
|
v.files.length <= REVERT_FILES_MAX &&
|
|
949
996
|
v.files.every(isRevertFile) &&
|
|
950
997
|
count(v.filesTotal) &&
|
|
951
|
-
(v.filesTotal as number) >= v.files.length
|
|
998
|
+
(v.filesTotal as number) >= v.files.length &&
|
|
999
|
+
(v.stagedAt === undefined || num(v.stagedAt))
|
|
952
1000
|
)
|
|
953
1001
|
}
|
|
954
1002
|
|
|
@@ -1187,8 +1235,11 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
|
|
|
1187
1235
|
(m.launchID === undefined || (str(m.launchID) && ID.test(m.launchID)))
|
|
1188
1236
|
? (m as unknown as Hello)
|
|
1189
1237
|
: undefined
|
|
1190
|
-
case "sessions":
|
|
1191
|
-
|
|
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
|
+
}
|
|
1192
1243
|
case "terminals":
|
|
1193
1244
|
return m.herdr === undefined || isHerdrInfo(m.herdr) ? (m as unknown as Terminals) : undefined
|
|
1194
1245
|
case "event":
|
|
@@ -1198,7 +1249,7 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
|
|
|
1198
1249
|
case "req":
|
|
1199
1250
|
return nonEmpty(m.id) && isSpokeRequest(m.req) ? (m as unknown as Req<SpokeRequest>) : undefined
|
|
1200
1251
|
case "res":
|
|
1201
|
-
return isRes(m) ? (m
|
|
1252
|
+
return isRes(m) ? lenientRes(m) : undefined
|
|
1202
1253
|
case "bye":
|
|
1203
1254
|
return isBye(m) ? (m as unknown as Bye) : undefined
|
|
1204
1255
|
default:
|
|
@@ -1206,6 +1257,32 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
|
|
|
1206
1257
|
}
|
|
1207
1258
|
}
|
|
1208
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
|
+
|
|
1209
1286
|
/** Parses a frame received by the spoke. Returns undefined for anything invalid. */
|
|
1210
1287
|
export function parseServerMessage(raw: string | Buffer): ServerMessage | undefined {
|
|
1211
1288
|
const m = parse(raw)
|
|
@@ -1216,7 +1293,7 @@ export function parseServerMessage(raw: string | Buffer): ServerMessage | undefi
|
|
|
1216
1293
|
case "req":
|
|
1217
1294
|
return nonEmpty(m.id) && isServerRequest(m.req) ? (m as unknown as Req<ServerRequest>) : undefined
|
|
1218
1295
|
case "res":
|
|
1219
|
-
return isRes(m) ? (m
|
|
1296
|
+
return isRes(m) ? lenientRes(m) : undefined
|
|
1220
1297
|
case "bye":
|
|
1221
1298
|
return isBye(m) ? (m as unknown as Bye) : undefined
|
|
1222
1299
|
default:
|
|
@@ -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
|
|
|
@@ -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",
|