@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,355 @@
|
|
|
1
|
+
// Workspace supervision graph (HWA-05; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md,
|
|
2
|
+
// packages/protocol/src/hub-workspace.ts SupervisionLink, supervisionInputProblem, supervisionViolation).
|
|
3
|
+
//
|
|
4
|
+
// A supervision link says "member M is supervised by member S" inside one workspace. Each member has at most one
|
|
5
|
+
// direct supervisor; the graph is acyclic and no member is more than `depth` links below a root (the contract's
|
|
6
|
+
// supervisionDepth, master = depth 0). Links name members, and a member is one generation of a session in the
|
|
7
|
+
// workspace, so a link always pins the supervisor's generation: a replacement (new generation, new member ID) only
|
|
8
|
+
// inherits links through an explicit memberDeparted(…, {replacedBy}).
|
|
9
|
+
//
|
|
10
|
+
// Storage: StateStore collection `workspace-supervision`, ONE aggregate per workspace (key: workspace ID) holding
|
|
11
|
+
// every link and the graph `revision` (0 before the first write, +1 per write). The StateStore has no cross-record
|
|
12
|
+
// transactions, so the whole graph is one version-checked write (ifVersion): a cycle or depth check is always made
|
|
13
|
+
// against the graph that is written over, and two concurrent opposing edges can never both land. Mutations name the
|
|
14
|
+
// graph revision they read (`expectedRevision`, required by the HTTP route, optional for internal callers); a
|
|
15
|
+
// mismatch is a 409 conflict with `currentRevision`. Without it, a lost race re-reads and re-checks, so the later
|
|
16
|
+
// edge of an opposing pair is refused as a cycle. Each link also carries its own `revision` (1 on attach, +1 per
|
|
17
|
+
// move or reorder); a detach removes it and a re-attach is a new link ID.
|
|
18
|
+
//
|
|
19
|
+
// Members: both ends must be current members of the workspace (unknown: not_found; departed: invalid), and the
|
|
20
|
+
// workspace's master cannot be given a supervisor (it is the root). Every write reads the supervision aggregate
|
|
21
|
+
// FIRST and the members second: any member a stored link names joined before that link was written, so it is in
|
|
22
|
+
// the later member read unless it was pruned for having departed. Departure is terminal, so each write drops the
|
|
23
|
+
// links whose ends are departed or no longer retained (`pruned`). Reads report such stale links truthfully
|
|
24
|
+
// (`left` / `missing`) rather than hiding them; memberDeparted (HWA-06 calls it after leave / sessionEnded /
|
|
25
|
+
// archive) detaches a departed member's links, or moves them to its replacement when the move stays acyclic and
|
|
26
|
+
// within depth (otherwise detached and counted). Archived workspaces read but refuse changes.
|
|
27
|
+
//
|
|
28
|
+
// Authority: supervision grants nothing and is separate from native session ancestry. This module never reads or
|
|
29
|
+
// writes parent/root session fields, grants, the directory or the registry: the own-tree permission/form ban and
|
|
30
|
+
// the root-only prompt rule of the acting engine are untouched. Events (HWA-06) carry IDs, revisions and counts.
|
|
31
|
+
//
|
|
32
|
+
// Limits: links per workspace (429 rate_limited at the cap; a move is not a new link), depth (400 invalid with
|
|
33
|
+
// extra.violation "depth"), cycles (400 invalid with extra.violation "cycle"), order a finite number within
|
|
34
|
+
// ±ORDER_MAX (fractions allowed so a drag can land between two siblings without renumbering them).
|
|
35
|
+
// Other builds: an aggregate with a newer `v` answers 422 unsupported and is left untouched; a malformed one is a
|
|
36
|
+
// 500. Rollback: older hubs never read this collection, so it stays dormant.
|
|
37
|
+
import { randomBytes } from "node:crypto"
|
|
38
|
+
import {
|
|
39
|
+
CONTRACT_VERSION,
|
|
40
|
+
ID_PREFIXES,
|
|
41
|
+
type IdKind,
|
|
42
|
+
isContractID,
|
|
43
|
+
type SupervisionLink,
|
|
44
|
+
supervisionInputProblem,
|
|
45
|
+
supervisionViolation,
|
|
46
|
+
WORKSPACE_LIMITS,
|
|
47
|
+
type WorkspaceMember,
|
|
48
|
+
} from "../../protocol"
|
|
49
|
+
import { AgentError } from "../agent/errors"
|
|
50
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
51
|
+
import type { MembershipStore } from "./members"
|
|
52
|
+
import type { WorkspaceStore } from "./store"
|
|
53
|
+
|
|
54
|
+
export const WORKSPACE_SUPERVISION = "workspace-supervision"
|
|
55
|
+
/** Largest |order| a link may carry. */
|
|
56
|
+
export const ORDER_MAX = 1e9
|
|
57
|
+
/** Version-checked write attempts before a contended change gives up. */
|
|
58
|
+
const ATTEMPTS = 16
|
|
59
|
+
|
|
60
|
+
export interface SupervisionLimits {
|
|
61
|
+
/** Links per workspace (the contract's supervisionLinksPerWorkspace). */
|
|
62
|
+
readonly linksPerWorkspace: number
|
|
63
|
+
/** Links from the deepest member up to its root (the contract's supervisionDepth). */
|
|
64
|
+
readonly depth: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const SUPERVISION_LIMITS: SupervisionLimits = {
|
|
68
|
+
linksPerWorkspace: WORKSPACE_LIMITS.supervisionLinksPerWorkspace,
|
|
69
|
+
depth: WORKSPACE_LIMITS.supervisionDepth,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SupervisionStoreDeps {
|
|
73
|
+
readonly state: StateStore
|
|
74
|
+
readonly workspaces: Pick<WorkspaceStore, "require">
|
|
75
|
+
readonly members: Pick<MembershipStore, "members">
|
|
76
|
+
readonly now?: Clock
|
|
77
|
+
readonly newID?: (kind: IdKind) => string
|
|
78
|
+
readonly limits?: Partial<SupervisionLimits>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A link end as the members read it: a current member, a departed one, or no longer retained. */
|
|
82
|
+
export type LinkEnd = "current" | "left" | "missing"
|
|
83
|
+
|
|
84
|
+
export interface SupervisionLinkView {
|
|
85
|
+
readonly link: SupervisionLink
|
|
86
|
+
readonly member: LinkEnd
|
|
87
|
+
readonly supervisor: LinkEnd
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface SupervisionGraph {
|
|
91
|
+
readonly workspaceID: string
|
|
92
|
+
/** The graph revision a mutation names as expectedRevision (0: never written). */
|
|
93
|
+
readonly revision: number
|
|
94
|
+
/** In storage order (stable); the dashboard sorts siblings by `order`. */
|
|
95
|
+
readonly links: readonly SupervisionLinkView[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type SupervisionChange = "attached" | "moved" | "reordered" | "detached" | "unchanged"
|
|
99
|
+
|
|
100
|
+
export interface SupervisionResult {
|
|
101
|
+
readonly workspaceID: string
|
|
102
|
+
/** The graph revision after the call. */
|
|
103
|
+
readonly revision: number
|
|
104
|
+
readonly change: SupervisionChange
|
|
105
|
+
/** The member's link after the call (absent when it has no supervisor). */
|
|
106
|
+
readonly link?: SupervisionLink
|
|
107
|
+
/** Stale links (departed or missing ends) dropped by this write. */
|
|
108
|
+
readonly pruned: number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface DepartureResult {
|
|
112
|
+
readonly workspaceID: string
|
|
113
|
+
readonly revision: number
|
|
114
|
+
/** Links naming the departed member that were removed. */
|
|
115
|
+
readonly detached: number
|
|
116
|
+
/** Links naming the departed member moved to its replacement. */
|
|
117
|
+
readonly transferred: number
|
|
118
|
+
/** Other stale links dropped by the same write. */
|
|
119
|
+
readonly pruned: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface SupervisionDoc {
|
|
123
|
+
readonly v: 1
|
|
124
|
+
readonly workspaceID: string
|
|
125
|
+
readonly revision: number
|
|
126
|
+
readonly links: readonly SupervisionLink[]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type Members = ReadonlyMap<string, WorkspaceMember>
|
|
130
|
+
|
|
131
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
132
|
+
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
|
|
133
|
+
|
|
134
|
+
const conflict = (currentRevision: number) =>
|
|
135
|
+
new AgentError("conflict", `The supervision graph changed since you read it (revision ${currentRevision} now)`, {
|
|
136
|
+
hint: "Read it again and retry with its current revision",
|
|
137
|
+
extra: { currentRevision },
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
/** The stored aggregate, or an empty graph; newer versions are unsupported, malformed ones internal. */
|
|
141
|
+
function readDoc(raw: unknown, workspaceID: string): SupervisionDoc {
|
|
142
|
+
if (raw === undefined) return { v: 1, workspaceID, revision: 0, links: [] }
|
|
143
|
+
if (isObj(raw) && isNum(raw.v) && raw.v > CONTRACT_VERSION)
|
|
144
|
+
throw new AgentError("unsupported", `Workspace ${workspaceID} supervision was written by a newer hub`, {
|
|
145
|
+
hint: "Upgrade this hub",
|
|
146
|
+
extra: { feature: WORKSPACE_SUPERVISION },
|
|
147
|
+
})
|
|
148
|
+
if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.workspaceID !== workspaceID || !Number.isSafeInteger(raw.revision) || !Array.isArray(raw.links))
|
|
149
|
+
throw new AgentError("internal", "Stored workspace supervision is malformed")
|
|
150
|
+
return raw as unknown as SupervisionDoc
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const endOf = (members: Members, id: string): LinkEnd => {
|
|
154
|
+
const member = members.get(id)
|
|
155
|
+
return !member ? "missing" : member.leftAt === undefined ? "current" : "left"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const isLive = (members: Members, link: SupervisionLink) => endOf(members, link.memberID) === "current" && endOf(members, link.supervisorMemberID) === "current"
|
|
159
|
+
|
|
160
|
+
const edges = (links: readonly SupervisionLink[]) => new Map(links.map((l) => [l.memberID, l.supervisorMemberID]))
|
|
161
|
+
|
|
162
|
+
function violationError(violation: "cycle" | "depth", depth: number): AgentError {
|
|
163
|
+
return violation === "cycle"
|
|
164
|
+
? new AgentError("invalid", "That supervisor is supervised by this member (directly or through others): it would make a cycle", {
|
|
165
|
+
hint: "Move the supervisor elsewhere first",
|
|
166
|
+
extra: { violation },
|
|
167
|
+
})
|
|
168
|
+
: new AgentError("invalid", `Supervision is at most ${depth} levels deep`, { hint: "Attach the member (or its subtree) higher up", extra: { violation, depth } })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A current member in the snapshot: not_found when unknown, invalid when departed. */
|
|
172
|
+
function current(members: Members, workspaceID: string, memberID: string): WorkspaceMember {
|
|
173
|
+
const member = members.get(memberID)
|
|
174
|
+
if (!member) throw new AgentError("not_found", `No member ${memberID} in workspace ${workspaceID}`)
|
|
175
|
+
if (member.leftAt !== undefined) throw new AgentError("invalid", `Member ${memberID} left the workspace`, { hint: "Use a current member" })
|
|
176
|
+
return member
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export class SupervisionStore {
|
|
180
|
+
readonly limits: SupervisionLimits
|
|
181
|
+
private readonly state: StateStore
|
|
182
|
+
private readonly newID: (kind: IdKind) => string
|
|
183
|
+
|
|
184
|
+
constructor(private readonly deps: SupervisionStoreDeps) {
|
|
185
|
+
this.state = deps.state
|
|
186
|
+
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
187
|
+
this.limits = { ...SUPERVISION_LIMITS, ...deps.limits }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The workspace's links with each end's membership state, and the graph revision. */
|
|
191
|
+
async graph(userID: string, workspaceID: string): Promise<SupervisionGraph> {
|
|
192
|
+
await this.deps.workspaces.require(userID, workspaceID)
|
|
193
|
+
const doc = readDoc((await this.state.get(userID, WORKSPACE_SUPERVISION, workspaceID))?.value, workspaceID)
|
|
194
|
+
const members = await this.memberMap(userID, workspaceID)
|
|
195
|
+
return {
|
|
196
|
+
workspaceID,
|
|
197
|
+
revision: doc.revision,
|
|
198
|
+
links: doc.links.map((link) => ({ link, member: endOf(members, link.memberID), supervisor: endOf(members, link.supervisorMemberID) })),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* PUT /workspaces/:id/supervision: `{memberID, supervisorMemberID | null, order?, expectedRevision}`. Attaches,
|
|
204
|
+
* moves, reorders or (null) detaches the member's one supervision link in a single version-checked write.
|
|
205
|
+
*/
|
|
206
|
+
async set(userID: string, workspaceID: string, input: unknown): Promise<SupervisionResult> {
|
|
207
|
+
if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
|
|
208
|
+
const expectedRevision = input.expectedRevision
|
|
209
|
+
const problem = supervisionInputProblem(expectedRevision === undefined ? { ...input, expectedRevision: 0 } : input)
|
|
210
|
+
if (problem) throw new AgentError("invalid", problem)
|
|
211
|
+
const memberID = input.memberID as string
|
|
212
|
+
const supervisorID = input.supervisorMemberID as string | null
|
|
213
|
+
const order = input.order
|
|
214
|
+
if (order !== undefined) {
|
|
215
|
+
if (supervisorID === null) throw new AgentError("invalid", "order applies to an attached member, not a detach")
|
|
216
|
+
if (!isNum(order) || Math.abs(order) > ORDER_MAX) throw new AgentError("invalid", `order must be a finite number within ±${ORDER_MAX}`)
|
|
217
|
+
}
|
|
218
|
+
const workspace = await this.deps.workspaces.require(userID, workspaceID)
|
|
219
|
+
if (workspace.state === "archived") throw new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
|
|
220
|
+
|
|
221
|
+
let result: Omit<SupervisionResult, "workspaceID" | "revision" | "pruned"> = { change: "unchanged" }
|
|
222
|
+
const written = await this.mutate(userID, workspaceID, (doc, members) => {
|
|
223
|
+
if (expectedRevision !== undefined && doc.revision !== expectedRevision) throw conflict(doc.revision)
|
|
224
|
+
const live = doc.links.filter((l) => isLive(members, l))
|
|
225
|
+
current(members, workspaceID, memberID)
|
|
226
|
+
if (supervisorID !== null) current(members, workspaceID, supervisorID)
|
|
227
|
+
const existing = live.find((l) => l.memberID === memberID)
|
|
228
|
+
result = { change: "unchanged", ...(existing ? { link: existing } : {}) }
|
|
229
|
+
let links = live
|
|
230
|
+
if (supervisorID === null) {
|
|
231
|
+
if (existing) {
|
|
232
|
+
links = live.filter((l) => l !== existing)
|
|
233
|
+
result = { change: "detached" }
|
|
234
|
+
}
|
|
235
|
+
} else if (existing && existing.supervisorMemberID === supervisorID) {
|
|
236
|
+
if (order !== undefined && order !== existing.order) {
|
|
237
|
+
const link = { ...existing, order: order as number, revision: existing.revision + 1 }
|
|
238
|
+
links = live.map((l) => (l === existing ? link : l))
|
|
239
|
+
result = { change: "reordered", link }
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
if (memberID === workspace.masterMemberID)
|
|
243
|
+
throw new AgentError("invalid", `Member ${memberID} is the master: the root of supervision has no supervisor`, { hint: "Replace the master first" })
|
|
244
|
+
const violation = supervisionViolation(edges(live), memberID, supervisorID, this.limits.depth)
|
|
245
|
+
if (violation) throw violationError(violation, this.limits.depth)
|
|
246
|
+
if (!existing && live.length >= this.limits.linksPerWorkspace)
|
|
247
|
+
throw new AgentError("rate_limited", `A workspace has at most ${this.limits.linksPerWorkspace} supervision links`, { hint: "Detach some first" })
|
|
248
|
+
const siblings = live.filter((l) => l.supervisorMemberID === supervisorID && l.memberID !== memberID).map((l) => l.order)
|
|
249
|
+
const at = (order as number | undefined) ?? (siblings.length ? Math.max(...siblings) + 1 : 0)
|
|
250
|
+
if (existing) {
|
|
251
|
+
const link = { ...existing, supervisorMemberID: supervisorID, order: at, revision: existing.revision + 1 }
|
|
252
|
+
links = live.map((l) => (l === existing ? link : l))
|
|
253
|
+
result = { change: "moved", link }
|
|
254
|
+
} else {
|
|
255
|
+
const link: SupervisionLink = { v: 1, id: this.newID("supervision"), workspaceID, supervisorMemberID: supervisorID, memberID, order: at, revision: 1 }
|
|
256
|
+
links = [...live, link]
|
|
257
|
+
result = { change: "attached", link }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return result.change === "unchanged" && live.length === doc.links.length ? undefined : links
|
|
261
|
+
})
|
|
262
|
+
return { workspaceID, ...result, revision: written.doc.revision, pruned: written.pruned }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A member departed (left, ended, archived or replaced): detaches its links, or with `replacedBy` (a current
|
|
267
|
+
* member, e.g. HWA-12's new master generation) moves them to the replacement where that stays acyclic, within
|
|
268
|
+
* depth and one supervisor per member; the rest are detached. Members it supervised without a transfer become
|
|
269
|
+
* roots. Refuses a current member (invalid): supervision follows membership, never the other way round.
|
|
270
|
+
*/
|
|
271
|
+
async memberDeparted(userID: string, workspaceID: string, memberID: string, options: { readonly replacedBy?: string } = {}): Promise<DepartureResult> {
|
|
272
|
+
if (!isContractID("member", memberID)) throw new AgentError("invalid", "memberID must be a workspace member ID")
|
|
273
|
+
const { replacedBy } = options
|
|
274
|
+
if (replacedBy !== undefined && !isContractID("member", replacedBy)) throw new AgentError("invalid", "replacedBy must be a workspace member ID")
|
|
275
|
+
const workspace = await this.deps.workspaces.require(userID, workspaceID)
|
|
276
|
+
let detached = 0
|
|
277
|
+
let transferred = 0
|
|
278
|
+
const written = await this.mutate(userID, workspaceID, (doc, members) => {
|
|
279
|
+
if (members.get(memberID)?.leftAt === undefined && members.has(memberID))
|
|
280
|
+
throw new AgentError("invalid", `Member ${memberID} is still current`, { hint: "Supervision follows membership: remove the member first" })
|
|
281
|
+
if (replacedBy !== undefined) current(members, workspaceID, replacedBy)
|
|
282
|
+
detached = 0
|
|
283
|
+
transferred = 0
|
|
284
|
+
const affected = (l: SupervisionLink) => l.memberID === memberID || l.supervisorMemberID === memberID
|
|
285
|
+
let links = doc.links.filter((l) => !affected(l) && isLive(members, l))
|
|
286
|
+
for (const old of doc.links.filter(affected)) {
|
|
287
|
+
const moved = replacedBy === undefined ? undefined : this.transfer(old, memberID, replacedBy, links, members, workspace.masterMemberID)
|
|
288
|
+
if (moved) {
|
|
289
|
+
links = [...links, moved]
|
|
290
|
+
transferred++
|
|
291
|
+
} else detached++
|
|
292
|
+
}
|
|
293
|
+
return detached + transferred === 0 && links.length === doc.links.length ? undefined : links
|
|
294
|
+
})
|
|
295
|
+
return { workspaceID, revision: written.doc.revision, detached, transferred, pruned: written.pruned - detached - transferred }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Drops links whose ends departed or are no longer retained (each write does this too). */
|
|
299
|
+
async prune(userID: string, workspaceID: string): Promise<{ workspaceID: string; revision: number; pruned: number }> {
|
|
300
|
+
await this.deps.workspaces.require(userID, workspaceID)
|
|
301
|
+
const written = await this.mutate(userID, workspaceID, (doc, members) => {
|
|
302
|
+
const live = doc.links.filter((l) => isLive(members, l))
|
|
303
|
+
return live.length === doc.links.length ? undefined : live
|
|
304
|
+
})
|
|
305
|
+
return { workspaceID, revision: written.doc.revision, pruned: written.pruned }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------- internals ----------
|
|
309
|
+
|
|
310
|
+
/** The old member's link rewritten to its replacement, or undefined when that is not allowed (then detached). */
|
|
311
|
+
private transfer(
|
|
312
|
+
old: SupervisionLink,
|
|
313
|
+
departed: string,
|
|
314
|
+
replacement: string,
|
|
315
|
+
links: readonly SupervisionLink[],
|
|
316
|
+
members: Members,
|
|
317
|
+
masterMemberID: string | undefined,
|
|
318
|
+
): SupervisionLink | undefined {
|
|
319
|
+
const memberID = old.memberID === departed ? replacement : old.memberID
|
|
320
|
+
const supervisorMemberID = old.supervisorMemberID === departed ? replacement : old.supervisorMemberID
|
|
321
|
+
if (memberID === supervisorMemberID || memberID === masterMemberID) return undefined
|
|
322
|
+
if (endOf(members, memberID) !== "current" || endOf(members, supervisorMemberID) !== "current") return undefined
|
|
323
|
+
if (links.some((l) => l.memberID === memberID)) return undefined
|
|
324
|
+
if (supervisionViolation(edges(links), memberID, supervisorMemberID, this.limits.depth)) return undefined
|
|
325
|
+
return { ...old, memberID, supervisorMemberID, revision: old.revision + 1 }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The workspace's members (current and departed) by ID. */
|
|
329
|
+
private async memberMap(userID: string, workspaceID: string): Promise<Members> {
|
|
330
|
+
const views = await this.deps.members.members(userID, workspaceID, { includeLeft: true })
|
|
331
|
+
return new Map(views.map((v) => [v.member.id, v.member]))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* One version-checked write of the aggregate: reads it, THEN the members (see the header), and writes the links
|
|
336
|
+
* `change` returns (undefined: nothing to write) at revision + 1. Retried when another writer got there first.
|
|
337
|
+
*/
|
|
338
|
+
private async mutate(
|
|
339
|
+
userID: string,
|
|
340
|
+
workspaceID: string,
|
|
341
|
+
change: (doc: SupervisionDoc, members: Members) => readonly SupervisionLink[] | undefined,
|
|
342
|
+
): Promise<{ doc: SupervisionDoc; pruned: number }> {
|
|
343
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
344
|
+
const stored = await this.state.get(userID, WORKSPACE_SUPERVISION, workspaceID)
|
|
345
|
+
const doc = readDoc(stored?.value, workspaceID)
|
|
346
|
+
const members = await this.memberMap(userID, workspaceID)
|
|
347
|
+
const links = change(doc, members)
|
|
348
|
+
if (links === undefined) return { doc, pruned: 0 }
|
|
349
|
+
const next: SupervisionDoc = { v: 1, workspaceID, revision: doc.revision + 1, links }
|
|
350
|
+
const put = await this.state.put(userID, WORKSPACE_SUPERVISION, workspaceID, next, { ifVersion: stored?.version ?? 0 })
|
|
351
|
+
if (put.ok) return { doc: next, pruned: doc.links.filter((l) => !isLive(members, l)).length }
|
|
352
|
+
}
|
|
353
|
+
throw new Error(`state update of ${WORKSPACE_SUPERVISION}/${workspaceID} kept conflicting`)
|
|
354
|
+
}
|
|
355
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,9 @@ import { HubRpc, VoiceRpc } from "./rpc"
|
|
|
11
11
|
import { jobFromInfo, type JobInfo } from "./protocol"
|
|
12
12
|
import { checkServer, parseServerInput } from "./setup"
|
|
13
13
|
import { defineVoiceAgent, orchestratorParts, STOP_TOOL, STOP_TOOL_DESCRIPTION } from "./spoke/conversation"
|
|
14
|
+
import { defineConciergeAgent } from "./spoke/concierge-agent"
|
|
15
|
+
|
|
16
|
+
import { CONCIERGE_AGENT, CONCIERGE_TOOLS } from "../packages/protocol/src/concierge.ts"
|
|
14
17
|
import { envInjector, hubBinDir, ShellCorrelator, stripInheritedHubEnv } from "./spoke/env-inject"
|
|
15
18
|
import { herdrRunner } from "./spoke/herdr"
|
|
16
19
|
import { launchSource } from "./spoke/launch-source"
|
|
@@ -18,14 +21,19 @@ import { HubRpcError, hubMsg, hubWho } from "./spoke/hub-rpc"
|
|
|
18
21
|
import { HUB_TOOL, HUB_TOOL_DESCRIPTION, HUB_TOOL_INPUT, runHubTool } from "./spoke/hub-tool"
|
|
19
22
|
import { cliEntry, installLaunchers, linkUserBin, standaloneHubctl } from "./spoke/launchers"
|
|
20
23
|
import { listServiceSessions } from "./spoke/listing"
|
|
21
|
-
import { ensureLocalHub } from "./spoke/localhub"
|
|
24
|
+
import { defaultStateDir, ensureLocalHub } from "./spoke/localhub"
|
|
25
|
+
import path from "node:path"
|
|
22
26
|
import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
|
|
23
27
|
import { PromptReferences } from "./spoke/prompt-refs"
|
|
28
|
+
import { PROFILE_TOOL, PROFILE_TOOL_DESCRIPTION, PROFILE_TOOL_INPUT } from "./spoke/profile-apply"
|
|
24
29
|
import { replyPermission, type PermissionLocation } from "./spoke/locations"
|
|
25
30
|
import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
|
|
26
31
|
import { joinProcess, type Membership } from "./spoke/singleton"
|
|
27
32
|
import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
|
|
28
33
|
|
|
34
|
+
/** Where the spoke keeps the IDs of Concierge sessions (always restricted to the Concierge's tools). */
|
|
35
|
+
const conciergeLockFile = () => path.join(defaultStateDir(), "concierge-sessions.json")
|
|
36
|
+
|
|
29
37
|
type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
|
|
30
38
|
|
|
31
39
|
/** Plugin.define without importing it (it returns its argument). */
|
|
@@ -54,18 +62,28 @@ function pluginVersion(): string {
|
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/**
|
|
57
|
-
* The most recent root sessions
|
|
58
|
-
*
|
|
65
|
+
* The most recent root sessions with their workspace (the spoke's session listing): the history window plus
|
|
66
|
+
* `include`. The background service lists every session of its server; a foreground `opencode serve` lists, from
|
|
67
|
+
* its own server, only the sessions of the locations it loaded (`scope`), so idle sessions of a loaded project are
|
|
68
|
+
* online without having run in this process, and nothing of other projects is claimed.
|
|
59
69
|
*/
|
|
60
|
-
async function listRecent(options?: { days: number; max: number; include: readonly string[] }) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
async function listRecent(kind: "service" | "standalone", scope: () => string[], options?: { days: number; max: number; include: readonly string[] }) {
|
|
71
|
+
if (kind === "service") {
|
|
72
|
+
const { Service } = await import("@opencode/client/service")
|
|
73
|
+
const { OpenCode } = await import("@opencode/client")
|
|
74
|
+
const endpoint = await Service.discover()
|
|
75
|
+
if (!endpoint) return []
|
|
76
|
+
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) ?? {} })
|
|
77
|
+
return listServiceSessions(client, options ?? {})
|
|
78
|
+
}
|
|
79
|
+
const client = await ownClient(kind)
|
|
80
|
+
if (!client) return []
|
|
81
|
+
return listServiceSessions(client, { ...(options ?? {}), scope: scope() })
|
|
67
82
|
}
|
|
68
83
|
|
|
84
|
+
/** This process can list sessions: the background service, or a foreground `opencode serve` that knows its password. */
|
|
85
|
+
const canList = (kind: "service" | "standalone") => kind === "service" || foregroundEndpoint(process.argv, process.env) !== undefined
|
|
86
|
+
|
|
69
87
|
/** A client for this process's own OpenCode server, when it can be reached (see src/spoke/own-server.ts). */
|
|
70
88
|
async function ownClient(kind: "service" | "standalone") {
|
|
71
89
|
const { OpenCode } = await import("@opencode/client")
|
|
@@ -78,6 +96,13 @@ async function ownClient(kind: "service" | "standalone") {
|
|
|
78
96
|
return endpoint ? OpenCode.make({ baseUrl: endpoint.url, headers: { authorization: endpoint.authorization } }) : undefined
|
|
79
97
|
}
|
|
80
98
|
|
|
99
|
+
/** The own server's client, or an error saying it cannot be reached. */
|
|
100
|
+
async function requireOwnClient(kind: "service" | "standalone") {
|
|
101
|
+
const client = await ownClient(kind).catch(() => undefined)
|
|
102
|
+
if (!client) throw new Error("This OpenCode process cannot reach its own server")
|
|
103
|
+
return client
|
|
104
|
+
}
|
|
105
|
+
|
|
81
106
|
/** Answers or cancels a form through the own server's API (the plugin API has no forms). */
|
|
82
107
|
async function replyForm(kind: "service" | "standalone", action: FormAction): Promise<"replied" | "gone" | "unavailable"> {
|
|
83
108
|
const client = await ownClient(kind).catch(() => undefined)
|
|
@@ -153,6 +178,9 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
153
178
|
const agent = ctx.agent as any
|
|
154
179
|
const mcp = (ctx as any).mcp
|
|
155
180
|
const vcs = (ctx as any).vcs
|
|
181
|
+
const skill = (ctx as any).skill
|
|
182
|
+
const command = (ctx as any).command
|
|
183
|
+
const names = (value: unknown) => (unwrap<ReadonlyArray<{ name?: unknown }>>(value, Array.isArray) ?? []).flatMap((item) => (typeof item?.name === "string" ? [item.name] : []))
|
|
156
184
|
return {
|
|
157
185
|
directory: ctx.location.directory,
|
|
158
186
|
opencodeVersion: ctx.app?.version ?? "unknown",
|
|
@@ -184,11 +212,30 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
184
212
|
const list = unwrap<Array<{ id: string; mode?: string; hidden?: boolean }>>(await agent.list({}), Array.isArray)
|
|
185
213
|
return list?.find((a) => a.mode !== "subagent" && !a.hidden)?.id
|
|
186
214
|
},
|
|
187
|
-
listRecent,
|
|
215
|
+
...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[] }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
|
|
188
216
|
activity: (directories) => activity(kind, directories),
|
|
217
|
+
// Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
|
|
218
|
+
...(canList(kind)
|
|
219
|
+
? {
|
|
220
|
+
forkSession: async ({ sessionID, before }: { sessionID: string; before: string }) => (await requireOwnClient(kind)).session.fork({ sessionID, before }),
|
|
221
|
+
revertStage: async (input: { sessionID: string; messageID: string; files: boolean }) => (await requireOwnClient(kind)).session.revert.stage(input),
|
|
222
|
+
revertClear: async (sessionID: string) => (await requireOwnClient(kind)).session.revert.clear({ sessionID }),
|
|
223
|
+
revertCommit: async (sessionID: string) => (await requireOwnClient(kind)).session.revert.commit({ sessionID }),
|
|
224
|
+
}
|
|
225
|
+
: {}),
|
|
189
226
|
createSession: async ({ directory, title }) =>
|
|
190
227
|
unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
|
|
191
228
|
...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
|
|
229
|
+
// Hub profiles (src/spoke/profile-apply.ts): local skills/commands win over a profile's; its own need the tool.
|
|
230
|
+
...(skill?.list && command?.list
|
|
231
|
+
? {
|
|
232
|
+
localNames: async (directory: string) => {
|
|
233
|
+
const [skills, commands] = await Promise.all([skill.list({ location: { directory } }), command.list({ location: { directory } })])
|
|
234
|
+
return { skills: names(skills), commands: names(commands) }
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
: {}),
|
|
238
|
+
profileTool: typeof (ctx as any).tool?.transform === "function",
|
|
192
239
|
...(vcs?.diff ? { vcsDiff: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await vcs.diff({ mode: "working", location: { directory } }), Array.isArray) } : {}),
|
|
193
240
|
...(vcs?.get
|
|
194
241
|
? {
|
|
@@ -250,11 +297,13 @@ export default definePlugin({
|
|
|
250
297
|
const common = { machineName: hostname(), kind, processID, pid: process.pid, pluginVersion: pluginVersion() }
|
|
251
298
|
if (source.kind === "launch") {
|
|
252
299
|
const nodeConfig = source.nodeConfig
|
|
253
|
-
return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig) })
|
|
300
|
+
return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig), conciergeLockFile: conciergeLockFile() })
|
|
254
301
|
}
|
|
255
302
|
return new Spoke(host, {
|
|
256
303
|
...common,
|
|
257
304
|
configFile: prepareConfigPath(),
|
|
305
|
+
// Concierge session locks survive a restart before the hub reconnects (src/spoke/session-context.ts).
|
|
306
|
+
conciergeLockFile: conciergeLockFile(),
|
|
258
307
|
// Loopback servers only, and only when autoStartLocalHub is on (see src/spoke/localhub.ts).
|
|
259
308
|
localHub: (server) => ensureLocalHub(server),
|
|
260
309
|
// One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts). Launch creates
|
|
@@ -269,12 +318,21 @@ export default definePlugin({
|
|
|
269
318
|
// Every registration is independent: they run together, so OpenCode's plugin activation (bounded at ~5 s on a
|
|
270
319
|
// cold start) waits for about one of them, not their sum.
|
|
271
320
|
const registrations: Promise<void>[] = [
|
|
272
|
-
attempt("register the voice
|
|
321
|
+
attempt("register the voice and concierge agents", () =>
|
|
322
|
+
ctx.agent.transform((editor) => {
|
|
323
|
+
defineVoiceAgent(editor as any)
|
|
324
|
+
defineConciergeAgent(editor as any)
|
|
325
|
+
}),
|
|
326
|
+
),
|
|
273
327
|
attempt("register the context hook", () =>
|
|
274
328
|
ctx.session.hook("context", (event) => {
|
|
275
329
|
const spoke = membership.active()
|
|
276
|
-
|
|
277
|
-
const
|
|
330
|
+
// (CON-04) A locked session (the Concierge) keeps its agent: no voice style, no orchestrator parts.
|
|
331
|
+
const locked = spoke?.contexts.locked(event.sessionID) ?? false
|
|
332
|
+
// The hub hears which agent a Concierge session runs on (it suspends one moved to another agent).
|
|
333
|
+
if (locked) spoke?.noteAgent(event.sessionID, event.agent)
|
|
334
|
+
if (!locked && spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
|
|
335
|
+
const parts = locked ? [] : orchestratorParts({
|
|
278
336
|
agent: event.agent,
|
|
279
337
|
conversation: spoke?.inConversation(event.sessionID) ?? false,
|
|
280
338
|
subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
|
|
@@ -282,6 +340,15 @@ export default definePlugin({
|
|
|
282
340
|
taskBoard: spoke?.taskBoard(event.sessionID),
|
|
283
341
|
})
|
|
284
342
|
for (const text of parts) event.system.push({ type: "text", text })
|
|
343
|
+
// A hub profile: after OpenCode's own parts (local instructions win), for this session only.
|
|
344
|
+
for (const text of spoke?.profiles.systemParts(event.sessionID) ?? []) event.system.push({ type: "text", text })
|
|
345
|
+
if (event.tools && !spoke?.profiles.toolVisible(event.sessionID)) delete event.tools[PROFILE_TOOL]
|
|
346
|
+
// (CON-04) The hub's session context: its parts after the profile's, its denied tools removed.
|
|
347
|
+
for (const text of spoke?.contexts.systemParts(event.sessionID) ?? []) event.system.push({ type: "text", text })
|
|
348
|
+
if (event.tools) spoke?.contexts.filterTools(event.sessionID, event.tools as Record<string, unknown>)
|
|
349
|
+
// The concierge agent keeps exactly CONCIERGE_TOOLS even without a hub context (a spoke that restarted before
|
|
350
|
+
// the hub re-sent it): OpenCode 2.2.3 offers `skill` whatever the agent's rules say.
|
|
351
|
+
if (event.tools && event.agent === CONCIERGE_AGENT) for (const name of Object.keys(event.tools)) if (!(CONCIERGE_TOOLS as readonly string[]).includes(name)) delete event.tools[name]
|
|
285
352
|
}),
|
|
286
353
|
),
|
|
287
354
|
// The voice agent stops one worker ("cancel the deploy one") without stopping itself or the others.
|
|
@@ -321,6 +388,21 @@ export default definePlugin({
|
|
|
321
388
|
}),
|
|
322
389
|
),
|
|
323
390
|
),
|
|
391
|
+
// A hub profile's skills and commands, for the calling session only (src/spoke/profile-apply.ts).
|
|
392
|
+
attempt("register the hub profile tool", () =>
|
|
393
|
+
(ctx as any).tool?.transform?.((editor: any) =>
|
|
394
|
+
editor.add({
|
|
395
|
+
name: PROFILE_TOOL,
|
|
396
|
+
description: PROFILE_TOOL_DESCRIPTION,
|
|
397
|
+
input: PROFILE_TOOL_INPUT,
|
|
398
|
+
options: { codemode: false },
|
|
399
|
+
execute: async (value: unknown, context: { sessionID: string }) => {
|
|
400
|
+
const spoke = membership.active()
|
|
401
|
+
return { content: spoke ? spoke.profiles.runTool(context.sessionID, value) : "The hub is not running in this OpenCode process; no hub profile is available." }
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
),
|
|
405
|
+
),
|
|
324
406
|
// @name / @@role in a prompt get context from the hub as an attached text file; the text is never changed
|
|
325
407
|
// and the harness's own @ meanings win (src/spoke/prompt-refs.ts, design §2.4).
|
|
326
408
|
attempt("register the prompt hook", () => {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// CON-04 the OpenCode agent the Concierge session runs on (contract "Agent"): registered by the plugin like the voice
|
|
2
|
+
// agent (conversation.ts defineVoiceAgent), mode primary, hidden. Its rules are an ALLOW-LIST: deny everything
|
|
3
|
+
// ("*"), then allow exactly CONCIERGE_TOOLS (hub, read, glob, grep, question). OpenCode evaluates the last matching
|
|
4
|
+
// rule, so nothing else (subagent/task, execute, webfetch/websearch, shell, edit/write/patch, todo, skill,
|
|
5
|
+
// external_directory, MCP or plugin tools, future tools) is allowed, even with no hub context. The hub also strips
|
|
6
|
+
// every tool off that list from each turn (sessionContext tools.allow): defence in depth. The default agent's rules
|
|
7
|
+
// are not inherited. Its instructions come from the hub with every turn (sessionContext), not from here.
|
|
8
|
+
import { CONCIERGE_AGENT, CONCIERGE_TOOLS } from "../../packages/protocol/src/concierge.ts"
|
|
9
|
+
import type { AgentEditorLike } from "./conversation"
|
|
10
|
+
|
|
11
|
+
export const CONCIERGE_AGENT_NAME = "Concierge"
|
|
12
|
+
export const CONCIERGE_AGENT_DESCRIPTION = "Hub Concierge: watches your sessions and acts only inside your grant. Hub-managed; not for direct use."
|
|
13
|
+
const CONCIERGE_SYSTEM = "You are the hub's Concierge. Your instructions, situation board and notes arrive from the hub with every turn."
|
|
14
|
+
|
|
15
|
+
export const CONCIERGE_RULES: ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" }> = [
|
|
16
|
+
{ action: "*", resource: "*", effect: "deny" },
|
|
17
|
+
...CONCIERGE_TOOLS.map((action) => ({ action, resource: "*", effect: "allow" as const })),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
/** Adds (or refreshes) the hub's "concierge" agent; a "concierge" agent the user defined is left alone. */
|
|
21
|
+
export function defineConciergeAgent(editor: AgentEditorLike): void {
|
|
22
|
+
// The description marks the hub's own agent, which is refreshed; a user's own "concierge" agent is left alone.
|
|
23
|
+
const existing = editor.get(CONCIERGE_AGENT)
|
|
24
|
+
if (existing && existing.description !== CONCIERGE_AGENT_DESCRIPTION) return
|
|
25
|
+
editor.update(CONCIERGE_AGENT, (agent) => {
|
|
26
|
+
agent.name = CONCIERGE_AGENT_NAME
|
|
27
|
+
agent.description = CONCIERGE_AGENT_DESCRIPTION
|
|
28
|
+
agent.mode = "primary"
|
|
29
|
+
agent.hidden = true
|
|
30
|
+
agent.system = CONCIERGE_SYSTEM
|
|
31
|
+
agent.permissions = CONCIERGE_RULES.map((r) => ({ ...r }))
|
|
32
|
+
})
|
|
33
|
+
}
|
package/src/spoke/deliver.ts
CHANGED
|
@@ -34,13 +34,17 @@ export interface RecipientState {
|
|
|
34
34
|
* (quirk hzmcqwpj), a steered one joins them in one call. With a form open OpenCode holds the message until the
|
|
35
35
|
* form is answered ("retained").
|
|
36
36
|
*
|
|
37
|
-
* `wake:false` and subagent sessions are admitted without starting a turn ("queued")
|
|
38
|
-
* queued item gets its own model call when a turn ends (that would wake
|
|
39
|
-
* without resume waits for the next turn and arrives before its prompt
|
|
37
|
+
* `wake:false` and subagent sessions are admitted without starting a turn ("queued"), always steered (also over
|
|
38
|
+
* the hub's "queue", RF-05): a queued item gets its own model call when a turn ends, resume or not (that would wake
|
|
39
|
+
* the session), while a steered one without resume waits for the next turn and arrives before its prompt. A
|
|
40
|
+
* steered one admitted into a running turn costs a model call when the turn would have ended, so routine ones are
|
|
41
|
+
* refused while the session is busy (deferredWhileBusy).
|
|
40
42
|
*/
|
|
41
43
|
export function planDelivery(req: { readonly wake: boolean; readonly meta: DeliverMeta }, state: RecipientState): DeliveryPlan {
|
|
42
44
|
const resume = req.wake && !state.subagent
|
|
43
|
-
|
|
45
|
+
// Not waking: always steer. OpenCode gives a queued item its own model call once a turn ends (resume or not),
|
|
46
|
+
// so a hub-chosen "queue" would wake the session after all; a steered one waits for the next turn.
|
|
47
|
+
const chosen = !resume ? "steer" : (req.meta.delivery ?? (req.meta.kind === "ask" ? "steer" : "queue"))
|
|
44
48
|
// Idle: no running turn to queue behind; steered, the wake shares its model call with what already waits.
|
|
45
49
|
const delivery = resume && !state.busy && !state.formOpen ? "steer" : chosen
|
|
46
50
|
return { delivery, resume, delivered: deliveredAs(delivery, resume, state) }
|
|
@@ -71,5 +75,19 @@ export function deliverMetadata(req: { readonly messageID: string; readonly meta
|
|
|
71
75
|
return { source: PROMPT_SOURCE, messageID: req.messageID, kind, from, to, ...(replyTo !== undefined ? { replyTo } : {}), ...(grant !== undefined ? { grant } : {}) }
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
/** The refusal of a routine quiet message to a busy session (the hub keeps it pending and retries). */
|
|
79
|
+
export const QUIET_WHILE_BUSY = "The session is busy: a message that does not wake it is admitted after the running turn"
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether a message waits for the running turn to end (refused while busy, RF-05): a routine `wake:false` msg (not
|
|
83
|
+
* urgent: the hub sends routine msgs with delivery "queue") to a top-level session. OpenCode adds anything admitted
|
|
84
|
+
* into a running turn with a model call of its own when the turn would have ended. Urgent messages, asks, replies,
|
|
85
|
+
* primers and anything for a subagent (never woken; it may not be idle before its parent needs the answer) go in at
|
|
86
|
+
* once and accept that call.
|
|
87
|
+
*/
|
|
88
|
+
export function deferredWhileBusy(req: { readonly wake: boolean; readonly meta: DeliverMeta }, subagent: boolean): boolean {
|
|
89
|
+
return !req.wake && !subagent && req.meta.kind === "msg" && req.meta.delivery !== "steer"
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
// The prompt envelope, InboxOrigins and DeliveryLedger live in the spoke SDK (harness-neutral).
|
|
75
93
|
export { deliverText, DeliveryLedger, InboxOrigins, renderPromptEnvelope, type Admission } from "../../packages/spoke-sdk/src/delivery.ts"
|