@huynle/hub 0.7.3 → 0.8.0
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 +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -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/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Coordination workspaces (HWA-02 contract; implemented by HWA-03..13). A "hub workspace" is a durable, owner-
|
|
2
|
+
// tenanted coordination record, distinct from a launch's LaunchWorkspace (a repo/worktree on one machine) and from
|
|
3
|
+
// any directory or Herdr key. It outlives sessions and launches: members, a replaceable master, supervision links,
|
|
4
|
+
// checkpoints and side chats are separate records that reference sessions by ID.
|
|
5
|
+
//
|
|
6
|
+
// Invariants frozen here:
|
|
7
|
+
// - Tenancy: one owner (the hub user). No implicit human sharing; membership grants nothing (grants stay explicit).
|
|
8
|
+
// - Supervision is a separate relationship, never a rewrite of native parentID/rootID.
|
|
9
|
+
// - The master is an ordinary public-API session; replacing it is an explicit mutation (HWA-12).
|
|
10
|
+
// - Events about these records are compact and text-free (IDs, kinds, revisions, counts only).
|
|
11
|
+
import { DESCRIPTION_MAX, isContractID, isExpectedRevision, isLabel, isText, metadataProblem, tagsProblem, type RecordState, type Revisioned } from "./contracts.ts"
|
|
12
|
+
import { isObj } from "./wire.ts"
|
|
13
|
+
|
|
14
|
+
export const WORKSPACE_LIMITS = {
|
|
15
|
+
/** Active (non-archived) workspaces per user. */
|
|
16
|
+
workspacesPerUser: 200,
|
|
17
|
+
/** Members (sessions) per workspace, current and departed generations together. */
|
|
18
|
+
membersPerWorkspace: 200,
|
|
19
|
+
/** Host repository bindings per workspace. */
|
|
20
|
+
bindingsPerWorkspace: 16,
|
|
21
|
+
/** Supervision links per workspace and the maximum supervision depth (master = depth 0). */
|
|
22
|
+
supervisionLinksPerWorkspace: 400,
|
|
23
|
+
supervisionDepth: 6,
|
|
24
|
+
/** Retained checkpoints per supervisor (oldest dropped). */
|
|
25
|
+
checkpointsPerSupervisor: 50,
|
|
26
|
+
/** Side chats per workspace and context items per side chat snapshot. */
|
|
27
|
+
sideChatsPerWorkspace: 100,
|
|
28
|
+
sideChatContextItems: 20,
|
|
29
|
+
/** Bytes of quoted text a side-chat context snapshot may carry, total. */
|
|
30
|
+
sideChatContextBytes: 64 * 1024,
|
|
31
|
+
/** Panes in one split layout. */
|
|
32
|
+
panes: 4,
|
|
33
|
+
} as const
|
|
34
|
+
|
|
35
|
+
/** Scopes for app tokens (registered by HWA-06 together with the routes; existing tokens gain nothing). */
|
|
36
|
+
export const WORKSPACE_SCOPES = ["workspaces:read", "workspaces:manage"] as const
|
|
37
|
+
|
|
38
|
+
export type WorkspaceRole = "master" | "worker" | "observer"
|
|
39
|
+
export const WORKSPACE_ROLES: readonly WorkspaceRole[] = ["master", "worker", "observer"]
|
|
40
|
+
|
|
41
|
+
export interface HubWorkspace extends Revisioned {
|
|
42
|
+
readonly v: 1
|
|
43
|
+
readonly id: string
|
|
44
|
+
readonly title: string
|
|
45
|
+
readonly description?: string
|
|
46
|
+
readonly state: RecordState
|
|
47
|
+
readonly tags?: readonly string[]
|
|
48
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
49
|
+
/** The current master membership (absent: no master; the workspace still exists). */
|
|
50
|
+
readonly masterMemberID?: string
|
|
51
|
+
readonly createdAt: number
|
|
52
|
+
readonly updatedAt: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Binds a workspace to a repository on a machine (explicit; logical IDs never derive from paths). */
|
|
56
|
+
export interface HostRepoBinding extends Revisioned {
|
|
57
|
+
readonly v: 1
|
|
58
|
+
readonly id: string
|
|
59
|
+
readonly workspaceID: string
|
|
60
|
+
readonly machineID: string
|
|
61
|
+
/** Absolute normal repo path on that machine (same checks as LaunchWorkspace.repo). */
|
|
62
|
+
readonly repo: string
|
|
63
|
+
readonly label?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A session's membership. A replaced session keeps its record (`leftAt`); a re-join is a new generation. */
|
|
67
|
+
export interface WorkspaceMember extends Revisioned {
|
|
68
|
+
readonly v: 1
|
|
69
|
+
readonly id: string
|
|
70
|
+
readonly workspaceID: string
|
|
71
|
+
readonly sessionID: string
|
|
72
|
+
readonly role: WorkspaceRole
|
|
73
|
+
/** Increments each time the same logical slot is re-staffed with a new session (master replacement, restart). */
|
|
74
|
+
readonly generation: number
|
|
75
|
+
readonly joinedAt: number
|
|
76
|
+
readonly leftAt?: number
|
|
77
|
+
readonly leftReason?: "removed" | "replaced" | "ended" | "archived"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A versioned unit of work given to a member (status only; instructions travel as messages). */
|
|
81
|
+
export interface WorkspaceAssignment extends Revisioned {
|
|
82
|
+
readonly v: 1
|
|
83
|
+
readonly id: string
|
|
84
|
+
readonly workspaceID: string
|
|
85
|
+
readonly memberID: string
|
|
86
|
+
readonly title: string
|
|
87
|
+
readonly status: "open" | "in_progress" | "blocked" | "done" | "cancelled"
|
|
88
|
+
/** External reference (e.g. a Brain task ID, a team intake ID); opaque to the hub. */
|
|
89
|
+
readonly ref?: string
|
|
90
|
+
readonly updatedAt: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Supervisor → supervised member. Acyclic within a workspace; depth ≤ supervisionDepth. */
|
|
94
|
+
export interface SupervisionLink extends Revisioned {
|
|
95
|
+
readonly v: 1
|
|
96
|
+
readonly id: string
|
|
97
|
+
readonly workspaceID: string
|
|
98
|
+
readonly supervisorMemberID: string
|
|
99
|
+
readonly memberID: string
|
|
100
|
+
/** Sibling order for the dashboard tree (drag reorder); dense integers are not required. */
|
|
101
|
+
readonly order: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A supervisor's durable acknowledgment of the events it has processed (HWA-11). `cursor` is the agent-events
|
|
106
|
+
* position; delivery of batched updates resumes after it. Written through (not debounced) in standalone mode.
|
|
107
|
+
*/
|
|
108
|
+
export interface SupervisorCheckpoint {
|
|
109
|
+
readonly v: 1
|
|
110
|
+
readonly id: string
|
|
111
|
+
readonly workspaceID: string
|
|
112
|
+
readonly supervisorMemberID: string
|
|
113
|
+
readonly cursor: string
|
|
114
|
+
readonly at: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A side chat: a new session started with an explicit, bounded, authorized snapshot of source context. The
|
|
119
|
+
* snapshot is copied at creation (never live access) and only from sources the creator could read.
|
|
120
|
+
*/
|
|
121
|
+
export interface SideChat extends Revisioned {
|
|
122
|
+
readonly v: 1
|
|
123
|
+
readonly id: string
|
|
124
|
+
readonly workspaceID: string
|
|
125
|
+
readonly sessionID: string
|
|
126
|
+
readonly sourceSessionID: string
|
|
127
|
+
/** Message IDs / artifact revision IDs the snapshot was taken from (text lives in the snapshot, not events). */
|
|
128
|
+
readonly sources: readonly { readonly kind: "message" | "artifact_revision"; readonly id: string }[]
|
|
129
|
+
readonly createdAt: number
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Pane layout persisted per device (HWA-07/08); a pane names a session or a side chat. */
|
|
133
|
+
export interface PaneLayout {
|
|
134
|
+
readonly v: 1
|
|
135
|
+
readonly split: "single" | "vertical" | "horizontal" | "grid"
|
|
136
|
+
readonly panes: readonly { readonly sessionID: string; readonly sideChatID?: string }[]
|
|
137
|
+
readonly focused: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Compact, text-free event types (family "manage"); data carries only IDs, kinds, revisions and counts. */
|
|
141
|
+
export const WORKSPACE_EVENT_TYPES = [
|
|
142
|
+
"workspace.created",
|
|
143
|
+
"workspace.updated",
|
|
144
|
+
"workspace.archived",
|
|
145
|
+
"workspace.member.joined",
|
|
146
|
+
"workspace.member.left",
|
|
147
|
+
"workspace.master.replaced",
|
|
148
|
+
"workspace.supervision.changed",
|
|
149
|
+
"workspace.assignment.changed",
|
|
150
|
+
"workspace.sidechat.created",
|
|
151
|
+
] as const
|
|
152
|
+
export type WorkspaceEventType = (typeof WORKSPACE_EVENT_TYPES)[number]
|
|
153
|
+
|
|
154
|
+
/** Why a create-workspace body is not acceptable, or undefined. */
|
|
155
|
+
export function workspaceInputProblem(v: unknown): string | undefined {
|
|
156
|
+
if (!isObj(v)) return "body must be an object"
|
|
157
|
+
if (!isLabel(v.title)) return "title must be 1–120 characters of text"
|
|
158
|
+
if (v.description !== undefined && !isText(v.description, DESCRIPTION_MAX)) return `description must be at most ${DESCRIPTION_MAX} characters`
|
|
159
|
+
return tagsProblem(v.tags) ?? metadataProblem(v.metadata)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Why a supervision mutation is not acceptable (shape only; cycle/depth checks need the graph, see wouldCycle). */
|
|
163
|
+
export function supervisionInputProblem(v: unknown): string | undefined {
|
|
164
|
+
if (!isObj(v)) return "body must be an object"
|
|
165
|
+
if (!isContractID("member", v.memberID)) return "memberID must be a workspace member ID"
|
|
166
|
+
if (v.supervisorMemberID !== null && !isContractID("member", v.supervisorMemberID)) return "supervisorMemberID must be a member ID or null (detach)"
|
|
167
|
+
if (v.supervisorMemberID === v.memberID) return "a member cannot supervise itself"
|
|
168
|
+
if (!isExpectedRevision(v.expectedRevision)) return "expectedRevision must be the workspace revision you read"
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* True when making `supervisor` supervise `member` would create a cycle or exceed `maxDepth`, given the current
|
|
174
|
+
* links (member → supervisor). The graph is small (≤ supervisionLinksPerWorkspace), so a walk is fine.
|
|
175
|
+
*/
|
|
176
|
+
export function supervisionViolation(
|
|
177
|
+
links: ReadonlyMap<string, string>,
|
|
178
|
+
member: string,
|
|
179
|
+
supervisor: string,
|
|
180
|
+
maxDepth: number = WORKSPACE_LIMITS.supervisionDepth,
|
|
181
|
+
): "cycle" | "depth" | undefined {
|
|
182
|
+
const next = new Map(links)
|
|
183
|
+
next.set(member, supervisor)
|
|
184
|
+
const depthOf = (start: string): number | "cycle" => {
|
|
185
|
+
const seen = new Set<string>()
|
|
186
|
+
let depth = 0
|
|
187
|
+
for (let at: string | undefined = start; at !== undefined; at = next.get(at)) {
|
|
188
|
+
if (seen.has(at)) return "cycle"
|
|
189
|
+
seen.add(at)
|
|
190
|
+
if (next.has(at)) depth++
|
|
191
|
+
}
|
|
192
|
+
return depth
|
|
193
|
+
}
|
|
194
|
+
let deepest = 0
|
|
195
|
+
for (const node of next.keys()) {
|
|
196
|
+
const d = depthOf(node)
|
|
197
|
+
if (d === "cycle") return "cycle"
|
|
198
|
+
deepest = Math.max(deepest, d)
|
|
199
|
+
}
|
|
200
|
+
return deepest > maxDepth ? "depth" : undefined
|
|
201
|
+
}
|
|
@@ -12,16 +12,27 @@
|
|
|
12
12
|
// Modules: harness-events.ts (the forwarded events a non-OpenCode spoke may send, with their shapes), launch.ts
|
|
13
13
|
// (launch vocabulary and workspace checks), node.ts (the `hub node` hello, frames and ops on the same socket),
|
|
14
14
|
// preset.ts (the harness preset schema), webhook.ts (webhook signature format), wire.ts (shared frame primitives).
|
|
15
|
+
// Workspaces/artifacts/teams contracts (HWA-02, docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md):
|
|
16
|
+
// contracts.ts (shared IDs, limits, revisions), hub-workspace.ts, profiles.ts, artifacts.ts, teams.ts, graph.ts.
|
|
15
17
|
// All are re-exported here.
|
|
16
18
|
|
|
17
19
|
import { featureList, HUB_URL_MAX_CHARS, ID, isByeFrame as isBye, isHubURL, isObj, isResFrame as isRes, isWelcomeFrame, MAX_FRAME, MAX_RETRY_AFTER_MS, nonEmpty, num, parseFrame as parse, str, token, UNSAFE_CHARS } from "./wire.ts"
|
|
18
20
|
import type { NodeMessage, NodeServerMessage } from "./node.ts"
|
|
21
|
+
import { PROFILE_APPLY_FEATURE, profileApplyRequestProblem, type ProfileApplyRequest, type ProfileApplyResult } from "./profiles.ts"
|
|
22
|
+
import { SESSION_CONTEXT_FEATURE, sessionContextProblem, type SessionContextRequest, type SessionContextResult } from "./concierge.ts"
|
|
19
23
|
|
|
20
24
|
export * from "./harness-events.ts"
|
|
21
25
|
export * from "./launch.ts"
|
|
22
26
|
export * from "./node.ts"
|
|
23
27
|
export * from "./preset.ts"
|
|
24
28
|
export * from "./webhook.ts"
|
|
29
|
+
export * from "./contracts.ts"
|
|
30
|
+
export * from "./hub-workspace.ts"
|
|
31
|
+
export * from "./concierge.ts"
|
|
32
|
+
export * from "./profiles.ts"
|
|
33
|
+
export * from "./artifacts.ts"
|
|
34
|
+
export * from "./teams.ts"
|
|
35
|
+
export * from "./graph.ts"
|
|
25
36
|
export { HUB_URL_MAX_CHARS, isHubURL, MAX_FRAME, MAX_RETRY_AFTER_MS }
|
|
26
37
|
|
|
27
38
|
export const PROTOCOL_VERSION = 1
|
|
@@ -137,6 +148,52 @@ export interface SessionInfo {
|
|
|
137
148
|
/** Open in a terminal right now (a Herdr pane or an OpenCode TUI showing it). */
|
|
138
149
|
readonly open?: boolean
|
|
139
150
|
readonly terminal?: TerminalInfo
|
|
151
|
+
/**
|
|
152
|
+
* A staged revert (feature "revert"): the session's history from `messageID` on is hidden and the files it changed
|
|
153
|
+
* since then are restored until the revert is restored (cleared) or committed (the next prompt commits it).
|
|
154
|
+
* Older hubs ignore it.
|
|
155
|
+
*/
|
|
156
|
+
readonly revert?: SessionRevertInfo
|
|
157
|
+
/**
|
|
158
|
+
* (CON-04, feature "sessionContext") Sessions the hub gave a locked context (the Concierge): the agent the spoke
|
|
159
|
+
* last saw the session run on, and the context revision it holds now (absent: none, e.g. after a restart). The hub
|
|
160
|
+
* re-sends a context the spoke does not hold and suspends a Concierge seen on another agent.
|
|
161
|
+
*/
|
|
162
|
+
readonly agent?: string
|
|
163
|
+
readonly contextRevision?: number
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Most files a revert summary or preview lists (`filesTotal` counts them all). */
|
|
167
|
+
export const REVERT_FILES_MAX = 50
|
|
168
|
+
/** Longest file path a revert summary carries. */
|
|
169
|
+
export const REVERT_FILE_MAX_CHARS = 1000
|
|
170
|
+
|
|
171
|
+
/** A file a revert restores (or would restore): its path in the session's directory and, when known, how it changes. */
|
|
172
|
+
export interface RevertFile {
|
|
173
|
+
readonly file: string
|
|
174
|
+
readonly status?: "added" | "deleted" | "modified"
|
|
175
|
+
readonly additions?: number
|
|
176
|
+
readonly deletions?: number
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A staged revert as the spoke reports it (SessionInfo.revert, the revert op's result). */
|
|
180
|
+
export interface SessionRevertInfo {
|
|
181
|
+
/** The reverted user message (it and everything after it are hidden). */
|
|
182
|
+
readonly messageID: string
|
|
183
|
+
/** User messages hidden: the reverted one and those after it. */
|
|
184
|
+
readonly hidden: number
|
|
185
|
+
/** When the reverted message was created (epoch ms), when known. */
|
|
186
|
+
readonly at?: number
|
|
187
|
+
/** Restored files (at most REVERT_FILES_MAX). */
|
|
188
|
+
readonly files: readonly RevertFile[]
|
|
189
|
+
readonly filesTotal: number
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** An attachment of a user message, by its position in the message's files (fetched through timelineFile). */
|
|
193
|
+
export interface MessageAttachment {
|
|
194
|
+
readonly index: number
|
|
195
|
+
readonly mime: string
|
|
196
|
+
readonly name?: string
|
|
140
197
|
}
|
|
141
198
|
|
|
142
199
|
export interface TerminalInfo {
|
|
@@ -355,8 +412,12 @@ export const IMPLIED_OPENCODE_FEATURES = [
|
|
|
355
412
|
* environment, for its own act verbs only (the OpenCode hub tool).
|
|
356
413
|
* "launch": its create op takes a launch's `launchID`, `agent` and `model` (see isLaunchCreate); a launch create's
|
|
357
414
|
* directory may be a new workspace under the machine's node workspace roots rather than one the spoke reported.
|
|
415
|
+
* "fork": it forks a session before one of its user messages (fork op). "revert": it stages, restores and commits
|
|
416
|
+
* a revert of a session from one of its user messages (revertPreview, revert, revertRestore, revertCommit ops) and
|
|
417
|
+
* reports a staged one as SessionInfo.revert. Announced only when the spoke's harness can (OpenCode: a process that
|
|
418
|
+
* reaches its own server).
|
|
358
419
|
*/
|
|
359
|
-
const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch"] as const
|
|
420
|
+
const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert"] as const
|
|
360
421
|
|
|
361
422
|
/**
|
|
362
423
|
* The core ops. An OpenCode spoke serves them all without announcing them; a spoke of another harness announces
|
|
@@ -370,15 +431,17 @@ export type CoreOp = (typeof CORE_OPS)[number]
|
|
|
370
431
|
* Every feature a spoke may serve: the core ops, the optional features, and "turnEvents": a spoke of another
|
|
371
432
|
* harness reports every turn's end as the harness confirms it (session.execution.succeeded, failed or interrupted,
|
|
372
433
|
* never inferred), so the hub trusts its "rpc" evidence for idle (e.g. a Codex app-server spoke's turn/completed).
|
|
373
|
-
* Not in SPOKE_FEATURES: an OpenCode spoke's native evidence already counts.
|
|
434
|
+
* Not in SPOKE_FEATURES: an OpenCode spoke's native evidence already counts. "profileApply" (HWA-15, profiles.ts):
|
|
435
|
+
* the spoke applies a pinned profile version to one session; announced only (never implied); the OpenCode spoke
|
|
436
|
+
* serves it since HWA-16 (src/spoke/profile-apply.ts).
|
|
374
437
|
*/
|
|
375
|
-
export const KNOWN_SPOKE_FEATURES = [...CORE_OPS, ...OPTIONAL_SPOKE_FEATURES, "turnEvents"] as const
|
|
438
|
+
export const KNOWN_SPOKE_FEATURES = [...CORE_OPS, ...OPTIONAL_SPOKE_FEATURES, "turnEvents", PROFILE_APPLY_FEATURE, SESSION_CONTEXT_FEATURE] as const
|
|
376
439
|
export type SpokeFeature = (typeof KNOWN_SPOKE_FEATURES)[number]
|
|
377
440
|
/**
|
|
378
441
|
* What this package's OpenCode spoke announces (without "deliver" and "hubPrompt" when its OpenCode has no synthetic
|
|
379
442
|
* messages). No core op: an OpenCode spoke implies them.
|
|
380
443
|
*/
|
|
381
|
-
export const SPOKE_FEATURES: readonly SpokeFeature[] = OPTIONAL_SPOKE_FEATURES
|
|
444
|
+
export const SPOKE_FEATURES: readonly SpokeFeature[] = [...OPTIONAL_SPOKE_FEATURES, PROFILE_APPLY_FEATURE, SESSION_CONTEXT_FEATURE]
|
|
382
445
|
|
|
383
446
|
/**
|
|
384
447
|
* Features the hub announces in its welcome. "harness": it reads hello `harness`, `harnessVersion` and `evidence`.
|
|
@@ -585,6 +648,28 @@ export type ServerRequest =
|
|
|
585
648
|
* `meta.from` and `meta.grant` around it, so the session never sees it as the user's prompt.
|
|
586
649
|
*/
|
|
587
650
|
| { readonly op: "deliver"; readonly sessionID: string; readonly messageID: string; readonly text: string; readonly wake: boolean; readonly meta: DeliverMeta }
|
|
651
|
+
/** Apply a pinned profile version to one session (feature "profileApply", HWA-15; see profiles.ts). */
|
|
652
|
+
| ProfileApplyRequest
|
|
653
|
+
/** A session's per-turn context (feature "sessionContext", CON-04; see concierge.ts). */
|
|
654
|
+
| SessionContextRequest
|
|
655
|
+
/**
|
|
656
|
+
* Fork the session before `messageID`, one of its user messages (feature "fork"): a new session with the
|
|
657
|
+
* conversation before that message. The result names it and carries the message's text and attachments, which
|
|
658
|
+
* the console puts in the new session's composer.
|
|
659
|
+
*/
|
|
660
|
+
| { readonly op: "fork"; readonly sessionID: string; readonly messageID: string }
|
|
661
|
+
/** What reverting from `messageID` would hide and which files the session changed since then (feature "revert"). */
|
|
662
|
+
| { readonly op: "revertPreview"; readonly sessionID: string; readonly messageID: string }
|
|
663
|
+
/**
|
|
664
|
+
* Stage a revert from `messageID`, one of the session's user messages (feature "revert"): it and everything after
|
|
665
|
+
* it are hidden and, with `files`, the files the session changed since then are restored. Refused while the
|
|
666
|
+
* session runs. The result carries the message's text and attachments for the composer.
|
|
667
|
+
*/
|
|
668
|
+
| { readonly op: "revert"; readonly sessionID: string; readonly messageID: string; readonly files: boolean }
|
|
669
|
+
/** Undo a staged revert: the messages and files come back (feature "revert"). */
|
|
670
|
+
| { readonly op: "revertRestore"; readonly sessionID: string }
|
|
671
|
+
/** Make a staged revert permanent: the hidden messages are removed (feature "revert"). */
|
|
672
|
+
| { readonly op: "revertCommit"; readonly sessionID: string }
|
|
588
673
|
|
|
589
674
|
/**
|
|
590
675
|
* The feature a spoke must serve (spokeFeatures) for the hub to send `op`: the op itself for a core op (CORE_OPS,
|
|
@@ -594,6 +679,10 @@ export function featureForOp(op: ServerRequest["op"]): SpokeFeature {
|
|
|
594
679
|
switch (op) {
|
|
595
680
|
case "setModelPrefs":
|
|
596
681
|
return "modelPrefs"
|
|
682
|
+
case "revertPreview":
|
|
683
|
+
case "revertRestore":
|
|
684
|
+
case "revertCommit":
|
|
685
|
+
return "revert"
|
|
597
686
|
default:
|
|
598
687
|
return op
|
|
599
688
|
}
|
|
@@ -710,6 +799,15 @@ export interface ResultFor {
|
|
|
710
799
|
/** As sessionTokens. */
|
|
711
800
|
actTokens: { readonly applied: readonly string[]; readonly skipped: ReadonlyArray<{ readonly sessionID: string; readonly reason: SessionTokenSkipReason }> }
|
|
712
801
|
deliver: DeliverResult
|
|
802
|
+
profileApply: ProfileApplyResult
|
|
803
|
+
sessionContext: SessionContextResult
|
|
804
|
+
/** `sessionID`: the new session; `text` and `attachments`: the user message it was forked before. */
|
|
805
|
+
fork: { readonly sessionID: string; readonly title?: string; readonly text: string; readonly attachments: readonly MessageAttachment[] }
|
|
806
|
+
/** `hidden`: user messages a revert would hide; `files`: files the session changed since then (at most REVERT_FILES_MAX). */
|
|
807
|
+
revertPreview: { readonly hidden: number; readonly files: readonly RevertFile[]; readonly filesTotal: number }
|
|
808
|
+
revert: { readonly revert: SessionRevertInfo; readonly text: string; readonly attachments: readonly MessageAttachment[] }
|
|
809
|
+
revertRestore: Record<string, never>
|
|
810
|
+
revertCommit: Record<string, never>
|
|
713
811
|
}
|
|
714
812
|
|
|
715
813
|
// ---------- both directions ----------
|
|
@@ -810,7 +908,41 @@ function isSession(v: unknown): v is SessionInfo {
|
|
|
810
908
|
(v.projectName === undefined || str(v.projectName)) &&
|
|
811
909
|
(v.archived === undefined || typeof v.archived === "boolean") &&
|
|
812
910
|
(v.open === undefined || typeof v.open === "boolean") &&
|
|
813
|
-
(v.terminal === undefined || isTerminal(v.terminal))
|
|
911
|
+
(v.terminal === undefined || isTerminal(v.terminal)) &&
|
|
912
|
+
(v.revert === undefined || isSessionRevert(v.revert)) &&
|
|
913
|
+
(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))
|
|
915
|
+
)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const REVERT_STATUS = new Set(["added", "deleted", "modified"])
|
|
919
|
+
const count = (v: unknown) => Number.isSafeInteger(v) && (v as number) >= 0
|
|
920
|
+
|
|
921
|
+
/** A file of a revert summary or preview. */
|
|
922
|
+
export function isRevertFile(v: unknown): v is RevertFile {
|
|
923
|
+
return (
|
|
924
|
+
isObj(v) &&
|
|
925
|
+
nonEmpty(v.file) &&
|
|
926
|
+
v.file.length <= REVERT_FILE_MAX_CHARS &&
|
|
927
|
+
(v.status === undefined || REVERT_STATUS.has(v.status as string)) &&
|
|
928
|
+
(v.additions === undefined || count(v.additions)) &&
|
|
929
|
+
(v.deletions === undefined || count(v.deletions))
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** A staged revert as a spoke reports it. */
|
|
934
|
+
export function isSessionRevert(v: unknown): v is SessionRevertInfo {
|
|
935
|
+
return (
|
|
936
|
+
isObj(v) &&
|
|
937
|
+
nonEmpty(v.messageID) &&
|
|
938
|
+
v.messageID.length <= 300 &&
|
|
939
|
+
count(v.hidden) &&
|
|
940
|
+
(v.at === undefined || num(v.at)) &&
|
|
941
|
+
Array.isArray(v.files) &&
|
|
942
|
+
v.files.length <= REVERT_FILES_MAX &&
|
|
943
|
+
v.files.every(isRevertFile) &&
|
|
944
|
+
count(v.filesTotal) &&
|
|
945
|
+
(v.filesTotal as number) >= v.files.length
|
|
814
946
|
)
|
|
815
947
|
}
|
|
816
948
|
|
|
@@ -1008,6 +1140,18 @@ function isServerRequest(v: unknown): v is ServerRequest {
|
|
|
1008
1140
|
return Array.isArray(v.tokens) && v.tokens.length <= SESSION_TOKENS_MAX && v.tokens.every(isSessionToken) && !("hubURL" in v)
|
|
1009
1141
|
case "deliver":
|
|
1010
1142
|
return deliverProblem(v) === undefined
|
|
1143
|
+
case "profileApply":
|
|
1144
|
+
return profileApplyRequestProblem(v) === undefined
|
|
1145
|
+
case "sessionContext":
|
|
1146
|
+
return sessionContextProblem(v) === undefined
|
|
1147
|
+
case "fork":
|
|
1148
|
+
case "revertPreview":
|
|
1149
|
+
return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300
|
|
1150
|
+
case "revert":
|
|
1151
|
+
return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && typeof v.files === "boolean"
|
|
1152
|
+
case "revertRestore":
|
|
1153
|
+
case "revertCommit":
|
|
1154
|
+
return nonEmpty(v.sessionID)
|
|
1011
1155
|
default:
|
|
1012
1156
|
return false
|
|
1013
1157
|
}
|
|
@@ -1102,15 +1246,34 @@ export const ACTIVITY_SUMMARY_MAX = 160
|
|
|
1102
1246
|
/** Longest reasoning delta forwarded; longer deltas are cut (they are normally a few tokens). */
|
|
1103
1247
|
export const REASONING_DELTA_MAX = 2000
|
|
1104
1248
|
|
|
1249
|
+
/** The hub tool's input fields that name its target (never `text`, `why` or answers: those are free text). */
|
|
1250
|
+
const HUB_TARGET_KEYS = ["propose", "to", "session", "id", "name", "role"] as const
|
|
1251
|
+
/** At most ACTIVITY_SUMMARY_MAX UTF-16 units, cut on a code point boundary (CON-10: never half an emoji). */
|
|
1252
|
+
function clipLine(line: string): string {
|
|
1253
|
+
if (line.length <= ACTIVITY_SUMMARY_MAX) return line
|
|
1254
|
+
let out = ""
|
|
1255
|
+
for (const ch of line) {
|
|
1256
|
+
if (out.length + ch.length > ACTIVITY_SUMMARY_MAX - 1) break
|
|
1257
|
+
out += ch
|
|
1258
|
+
}
|
|
1259
|
+
// A joiner or variation selector left dangling would glue the ellipsis to half a sequence.
|
|
1260
|
+
return `${out.replace(/[\u200d\ufe0f]+$/u, "")}…`
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1105
1263
|
/** Short human summary of a tool input, e.g. the shell command or file path. Never the full input. */
|
|
1106
1264
|
export function summarizeToolInput(input: unknown): string {
|
|
1107
1265
|
if (typeof input !== "object" || input === null) return ""
|
|
1108
1266
|
const record = input as Record<string, unknown>
|
|
1267
|
+
// (CON-09b) The hub tool: its verb and target, e.g. "msg @me", "approve @engineer per_1", "concierge_board".
|
|
1268
|
+
if (typeof record.verb === "string" && /^[a-z][a-z_]{0,39}$/.test(record.verb)) {
|
|
1269
|
+
const target = HUB_TARGET_KEYS.map((key) => record[key]).filter((v): v is string => typeof v === "string" && v.trim() !== "" && !/\s/.test(v.trim()))
|
|
1270
|
+
return clipLine([record.verb, ...target.map((v) => v.trim())].join(" "))
|
|
1271
|
+
}
|
|
1109
1272
|
for (const key of SUMMARY_KEYS) {
|
|
1110
1273
|
const value = record[key]
|
|
1111
1274
|
if (typeof value === "string" && value.trim()) {
|
|
1112
1275
|
const line = value.trim().split("\n")[0]!
|
|
1113
|
-
return line
|
|
1276
|
+
return clipLine(line)
|
|
1114
1277
|
}
|
|
1115
1278
|
}
|
|
1116
1279
|
return ""
|
|
@@ -59,6 +59,15 @@ export interface LaunchEnd {
|
|
|
59
59
|
readonly cleanup?: CleanupPolicy
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* (HWA-15, additive) The agent profile version a launched session gets before its first prompt. The version is
|
|
64
|
+
* explicit: a launch never follows a profile's latest version silently.
|
|
65
|
+
*/
|
|
66
|
+
export interface LaunchAgentProfile {
|
|
67
|
+
readonly profileID: string
|
|
68
|
+
readonly version: number
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
/** A launch request body (POST /api/agent/launches). Phase 4 has no `grant` field: a launch gets no powers beyond its launcher's. */
|
|
63
72
|
export interface LaunchSpec {
|
|
64
73
|
/** Idempotency key: the same key and body return the same launch. */
|
|
@@ -73,9 +82,13 @@ export interface LaunchSpec {
|
|
|
73
82
|
readonly runtime?: LaunchRuntime
|
|
74
83
|
readonly placement?: LaunchPlacement
|
|
75
84
|
readonly prompt?: string
|
|
85
|
+
/** (CON-02) What the session is for: one line, at most 280 characters, untrusted; stored as its intent (source "launch"). */
|
|
86
|
+
readonly intent?: string
|
|
76
87
|
/** Free-form; never interpreted by the hub. */
|
|
77
88
|
readonly labels?: Readonly<Record<string, string>>
|
|
78
89
|
readonly end?: LaunchEnd
|
|
90
|
+
/** (HWA-15) Pin and apply this profile version to the session before its prompt (spokes that announce profileApply). */
|
|
91
|
+
readonly agentProfile?: LaunchAgentProfile
|
|
79
92
|
}
|
|
80
93
|
|
|
81
94
|
/** What a node collected from a launch's workspace. */
|
|
@@ -170,3 +183,12 @@ export function labelsProblem(v: unknown): string | undefined {
|
|
|
170
183
|
}
|
|
171
184
|
return undefined
|
|
172
185
|
}
|
|
186
|
+
|
|
187
|
+
/** Why `v` is not an acceptable launch agentProfile ({profileID, version}), or undefined. */
|
|
188
|
+
export function launchAgentProfileProblem(v: unknown): string | undefined {
|
|
189
|
+
if (!isObj(v)) return "agentProfile must be {profileID, version}"
|
|
190
|
+
if (Object.keys(v).some((k) => k !== "profileID" && k !== "version")) return "agentProfile holds only profileID and version"
|
|
191
|
+
if (typeof v.profileID !== "string" || !/^[A-Za-z0-9_.:-]{1,100}$/.test(v.profileID)) return "agentProfile.profileID must be a profile ID"
|
|
192
|
+
if (typeof v.version !== "number" || !Number.isSafeInteger(v.version) || v.version < 1) return "agentProfile.version must be a version number (explicit; never latest)"
|
|
193
|
+
return undefined
|
|
194
|
+
}
|