@huynle/hub 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +326 -1
- package/README.md +85 -16
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +723 -281
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +9 -4
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +61 -3
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +90 -7
- package/packages/protocol/src/launch.ts +14 -0
- package/packages/protocol/src/wire.ts +1 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +3 -1
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +70 -12
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +111 -28
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +39 -10
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +60 -17
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +11 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +10 -6
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +87 -35
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +49 -4
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +6 -3
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +24 -2
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +63 -30
- package/src/migrate.ts +109 -3
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +26 -12
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +120 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +183 -33
- package/src/spoke/spoke.ts +129 -41
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +17 -1
- package/src/status.ts +2 -1
- package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
- package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
- package/web/dist/assets/Library-BXbJvTUS.js +2 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
- package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
- package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-g4V9mfSr.js +34 -0
- package/web/dist/assets/live-OgQWPrSY.js +5 -0
- package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- package/web/dist/assets/ui-C9Igxzjm.js +0 -1
package/src/central/registry.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// another one and reaches it through SpokeHub.request (pod RPC). Events, alerts and speech stay with the pod that
|
|
12
12
|
// holds the connection; remote state is never re-exported.
|
|
13
13
|
import path from "node:path"
|
|
14
|
-
import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
|
|
14
|
+
import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type RevertCommitted, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
|
|
15
15
|
import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
16
16
|
import { sanitizeLabel } from "./agent/envelope"
|
|
17
17
|
import { DoneWatch } from "./done"
|
|
@@ -224,6 +224,8 @@ export interface SessionView extends BrowserFields {
|
|
|
224
224
|
readonly terminal?: TerminalInfo
|
|
225
225
|
/** A staged revert (feature "revert"), as the most recent report says. */
|
|
226
226
|
readonly revert?: SessionRevertInfo
|
|
227
|
+
/** The latest committed revert's removed window (feature "revert"), as the most recent report says. */
|
|
228
|
+
readonly revertCommitted?: RevertCommitted
|
|
227
229
|
/** (CON-04) A locked (Concierge) session: the agent and the context revision the most recent report says. */
|
|
228
230
|
readonly agent?: string
|
|
229
231
|
readonly contextRevision?: number
|
|
@@ -253,6 +255,8 @@ export interface CatalogProcess {
|
|
|
253
255
|
readonly connected: number
|
|
254
256
|
readonly lastSeen: number
|
|
255
257
|
readonly sessions: CatalogSession[]
|
|
258
|
+
/** (FX-07) Connected, but its first sessions frame has not come yet: an empty `sessions` says nothing then. */
|
|
259
|
+
readonly sessionsPending?: true
|
|
256
260
|
}
|
|
257
261
|
|
|
258
262
|
/** A machine's terminal context: its Herdr workspaces (latest terminals frame of the machine). */
|
|
@@ -279,6 +283,8 @@ interface Conn {
|
|
|
279
283
|
sessions: Map<string, SessionInfo>
|
|
280
284
|
/** The last sessions frame as JSON, to skip unchanged snapshots. */
|
|
281
285
|
sessionsKey: string
|
|
286
|
+
/** (FX-07) No sessions frame yet. */
|
|
287
|
+
sessionsPending?: true
|
|
282
288
|
/** sessionID → activity sequence number of the last event this process sent for it. */
|
|
283
289
|
activity: Map<string, number>
|
|
284
290
|
/**
|
|
@@ -304,6 +310,8 @@ export interface SliceConn {
|
|
|
304
310
|
readonly launchID?: string
|
|
305
311
|
/** The machine's terminal context, when this connection sent the latest one. */
|
|
306
312
|
readonly terminals?: MachineTerminals
|
|
313
|
+
/** (FX-07) No sessions frame yet (absent from older pods: reported). */
|
|
314
|
+
readonly sessionsPending?: true
|
|
307
315
|
}
|
|
308
316
|
|
|
309
317
|
/** A root session's status tree (live: run starts and recommendations included) and activity feed. */
|
|
@@ -547,6 +555,12 @@ export class Registry {
|
|
|
547
555
|
return conn && this.tenant(conn)
|
|
548
556
|
}
|
|
549
557
|
|
|
558
|
+
/** The committed revert windows the connection reports for its own tenant's sessions (feature "revert"). */
|
|
559
|
+
revertCommits(connID: string): Array<{ sessionID: string; committed: RevertCommitted }> {
|
|
560
|
+
const conn = this.conn(connID)
|
|
561
|
+
return conn ? this.visible(conn).flatMap((s) => (s.revertCommitted ? [{ sessionID: s.id, committed: s.revertCommitted }] : [])) : []
|
|
562
|
+
}
|
|
563
|
+
|
|
550
564
|
/** IDs of the sessions the connection reports for its own tenant (subagents included); empty when unknown. */
|
|
551
565
|
reportedSessions(connID: string): string[] {
|
|
552
566
|
const conn = this.conn(connID)
|
|
@@ -598,6 +612,7 @@ export class Registry {
|
|
|
598
612
|
lastSeen: t,
|
|
599
613
|
sessions: new Map(),
|
|
600
614
|
sessionsKey: "[]",
|
|
615
|
+
sessionsPending: true,
|
|
601
616
|
activity: new Map(),
|
|
602
617
|
})
|
|
603
618
|
this.changed(this.tenant(this.conns.get(connID)!))
|
|
@@ -640,7 +655,9 @@ export class Registry {
|
|
|
640
655
|
conn.lastSeen = this.now()
|
|
641
656
|
// Spokes re-send their snapshot periodically; only changes reach the phones.
|
|
642
657
|
const key = JSON.stringify(sessions)
|
|
643
|
-
|
|
658
|
+
const first = conn.sessionsPending === true
|
|
659
|
+
delete conn.sessionsPending
|
|
660
|
+
if (key !== conn.sessionsKey || first) {
|
|
644
661
|
conn.sessionsKey = key
|
|
645
662
|
conn.sessions = new Map(sessions.map((s) => [s.id, s]))
|
|
646
663
|
this.syncBoards()
|
|
@@ -1165,6 +1182,15 @@ export class Registry {
|
|
|
1165
1182
|
}
|
|
1166
1183
|
|
|
1167
1184
|
/** Something in the session's tree is working or waiting for the user (typed input steers then). */
|
|
1185
|
+
/**
|
|
1186
|
+
* Whether a process reports a staged revert for the session (feature "revert"): anything admitted into it would
|
|
1187
|
+
* commit the revert (OpenCode's next prompt does), so hub deliveries and act prompts wait or are refused.
|
|
1188
|
+
*/
|
|
1189
|
+
revertStaged(sessionID: string): boolean {
|
|
1190
|
+
for (const conn of this.all()) if (this.reports(conn, sessionID)?.revert) return true
|
|
1191
|
+
return false
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1168
1194
|
running(sessionID: string): boolean {
|
|
1169
1195
|
const state = this.browserFields(sessionID).state
|
|
1170
1196
|
return state === "working" || state === "blocked"
|
|
@@ -1232,6 +1258,7 @@ export class Registry {
|
|
|
1232
1258
|
features: [...conn.features].sort(),
|
|
1233
1259
|
connected: conn.connected,
|
|
1234
1260
|
lastSeen: conn.lastSeen,
|
|
1261
|
+
...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
|
|
1235
1262
|
sessions: this.visible(conn).map((s) => {
|
|
1236
1263
|
const fields = this.browserFields(s.id)
|
|
1237
1264
|
return {
|
|
@@ -1249,6 +1276,19 @@ export class Registry {
|
|
|
1249
1276
|
return [...machines.values()]
|
|
1250
1277
|
}
|
|
1251
1278
|
|
|
1279
|
+
/**
|
|
1280
|
+
* (FX-07) The tenant's sessions a spoke reports locked now (the Concierge's: `locked`, from the first frame after a
|
|
1281
|
+
* restart, and `agent` / `contextRevision`, sent only for locked sessions), on this pod's connections and other pods'.
|
|
1282
|
+
*/
|
|
1283
|
+
lockedSessions(tenant: string): string[] {
|
|
1284
|
+
const out = new Set<string>()
|
|
1285
|
+
for (const conn of this.all()) {
|
|
1286
|
+
if (this.tenant(conn) !== tenant) continue
|
|
1287
|
+
for (const s of conn.sessions.values()) if (s.locked === true || s.agent !== undefined || s.contextRevision !== undefined) out.add(s.id)
|
|
1288
|
+
}
|
|
1289
|
+
return [...out]
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1252
1292
|
/** The session as reported by any process plus its live status; undefined when nobody reports it. */
|
|
1253
1293
|
session(sessionID: string): SessionView | undefined {
|
|
1254
1294
|
let info: SessionInfo | undefined
|
|
@@ -1273,6 +1313,7 @@ export class Registry {
|
|
|
1273
1313
|
updated: info.updated,
|
|
1274
1314
|
...(terminal ? { open: true, terminal } : {}),
|
|
1275
1315
|
...(info.revert ? { revert: info.revert } : {}),
|
|
1316
|
+
...(info.revertCommitted ? { revertCommitted: info.revertCommitted } : {}),
|
|
1276
1317
|
...(info.agent !== undefined ? { agent: info.agent } : {}),
|
|
1277
1318
|
...(info.contextRevision !== undefined ? { contextRevision: info.contextRevision } : {}),
|
|
1278
1319
|
...fields,
|
|
@@ -1722,10 +1763,12 @@ export class Registry {
|
|
|
1722
1763
|
recommend(requestID: string, recommendation: BlockerRecommendation): boolean {
|
|
1723
1764
|
const origin = this.permissions.get(requestID)
|
|
1724
1765
|
if (!origin) return false
|
|
1725
|
-
|
|
1766
|
+
// (R-03) Without recommends it only links the Decision: no why either.
|
|
1767
|
+
const recommends = recommendation.recommends === "approve"
|
|
1768
|
+
const why = !recommends || recommendation.why === undefined ? "" : sanitizeLabel(recommendation.why, RECOMMENDATION_WHY_MAX)
|
|
1726
1769
|
origin.recommendation = {
|
|
1727
1770
|
by: sanitizeLabel(recommendation.by, RECOMMENDATION_BY_MAX) || "An agent",
|
|
1728
|
-
recommends: "approve",
|
|
1771
|
+
...(recommends ? { recommends: "approve" as const } : {}),
|
|
1729
1772
|
...(why ? { why } : {}),
|
|
1730
1773
|
...(recommendation.decisionID && DECISION_ID.test(recommendation.decisionID) ? { decisionID: recommendation.decisionID } : {}),
|
|
1731
1774
|
}
|
|
@@ -1893,6 +1936,7 @@ export class Registry {
|
|
|
1893
1936
|
activity: [...conn.activity],
|
|
1894
1937
|
...(conn.launchID !== undefined ? { launchID: conn.launchID } : {}),
|
|
1895
1938
|
...(terminals?.connID === conn.id ? { terminals: terminals.terminals } : {}),
|
|
1939
|
+
...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
|
|
1896
1940
|
}
|
|
1897
1941
|
})
|
|
1898
1942
|
const boards: SliceBoard[] = [...this.boards]
|
|
@@ -1966,6 +2010,7 @@ export class Registry {
|
|
|
1966
2010
|
sessionsKey: "",
|
|
1967
2011
|
activity: new Map(c.activity ?? []),
|
|
1968
2012
|
...(c.launchID !== undefined ? { launchID: c.launchID } : {}),
|
|
2013
|
+
...(c.sessionsPending === true ? { sessionsPending: true as const } : {}),
|
|
1969
2014
|
remote: { pod, tenant },
|
|
1970
2015
|
})
|
|
1971
2016
|
if (c.terminals) terminals.set(c.machine.id, c.terminals)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Committed revert windows (feature "revert", docs/session-view.md → Fork and revert). A committed revert removed a
|
|
2
|
+
// session's history from the reverted message on; hub message rows (agent messages the session sent or got) from
|
|
3
|
+
// the reverted message's time until the revert was staged belonged to that history (later ones were held for after
|
|
4
|
+
// it, decision 3q94ez94). The spoke reports the latest window (SessionInfo.revertCommitted); the hub keeps every
|
|
5
|
+
// window per (user, session) in the StateStore, so GET /api/sessions/:id/messages leaves those rows out for good
|
|
6
|
+
// (also after the spoke restarts or another process reports the session).
|
|
7
|
+
import type { RevertCommitted } from "../protocol"
|
|
8
|
+
import type { StateStore } from "./backend/types"
|
|
9
|
+
|
|
10
|
+
const COLLECTION = "revertWindows"
|
|
11
|
+
/** Windows kept per session (the oldest go first). */
|
|
12
|
+
export const REVERT_WINDOWS_MAX = 50
|
|
13
|
+
|
|
14
|
+
export interface RevertWindow {
|
|
15
|
+
readonly from: number
|
|
16
|
+
readonly until: number
|
|
17
|
+
/** Commit time (identifies the commit). */
|
|
18
|
+
readonly at: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Stored {
|
|
22
|
+
readonly windows: readonly RevertWindow[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether a row at `at` falls into one of the windows. */
|
|
26
|
+
export function inRevertWindow(at: unknown, windows: readonly RevertWindow[]): boolean {
|
|
27
|
+
return typeof at === "number" && windows.some((w) => at >= w.from && at < w.until)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class RevertWindows {
|
|
31
|
+
/** Windows known stored (by user, session and the whole window): the frames that repeat them read nothing. */
|
|
32
|
+
private readonly stored = new Set<string>()
|
|
33
|
+
|
|
34
|
+
constructor(private readonly state: StateStore) {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A reported window (each sessions frame repeats it): kept unless known stored. Only a stored (or already present)
|
|
38
|
+
* window is remembered, so one reported first without its start (nothing to store), or whose write failed, is taken
|
|
39
|
+
* again from the next frame. Errors are the caller's.
|
|
40
|
+
*/
|
|
41
|
+
async observe(userID: string, sessionID: string, committed: RevertCommitted): Promise<void> {
|
|
42
|
+
if (committed.from === undefined || committed.until <= committed.from) return
|
|
43
|
+
const key = JSON.stringify([userID, sessionID, committed.from, committed.until, committed.at])
|
|
44
|
+
if (this.stored.has(key)) return
|
|
45
|
+
if (await this.record(userID, sessionID, committed)) this.stored.add(key)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async list(userID: string, sessionID: string): Promise<readonly RevertWindow[]> {
|
|
49
|
+
return (await this.state.get<Stored>(userID, COLLECTION, sessionID))?.value.windows ?? []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Keeps a reported window (one without a start removes no rows: skipped). True when it is stored now (new, or
|
|
54
|
+
* already there); false when skipped or the write lost a race (version check).
|
|
55
|
+
*/
|
|
56
|
+
async record(userID: string, sessionID: string, committed: RevertCommitted): Promise<boolean> {
|
|
57
|
+
if (committed.from === undefined || committed.until <= committed.from) return false
|
|
58
|
+
const window: RevertWindow = { from: committed.from, until: committed.until, at: committed.at }
|
|
59
|
+
const current = await this.state.get<Stored>(userID, COLLECTION, sessionID)
|
|
60
|
+
const windows = current?.value.windows ?? []
|
|
61
|
+
if (windows.some((w) => w.at === window.at && w.from === window.from && w.until === window.until)) return true
|
|
62
|
+
const next = [...windows, window].slice(-REVERT_WINDOWS_MAX)
|
|
63
|
+
const result = await this.state.put<Stored>(userID, COLLECTION, sessionID, { windows: next }, current ? { ifVersion: current.version } : {})
|
|
64
|
+
return result.ok
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/central/spoke-ws.ts
CHANGED
|
@@ -61,9 +61,12 @@ export interface Notifier {
|
|
|
61
61
|
|
|
62
62
|
/** The spoke answered with an error, or disconnected before answering. */
|
|
63
63
|
export class SpokeError extends Error {
|
|
64
|
-
|
|
64
|
+
/** The refusal's protocol code (Res.code, e.g. "busy", "not_found"), when the spoke sent one. */
|
|
65
|
+
readonly code?: string
|
|
66
|
+
constructor(message: string, code?: string) {
|
|
65
67
|
super(message)
|
|
66
68
|
this.name = "SpokeError"
|
|
69
|
+
if (code !== undefined) this.code = code
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -669,7 +672,7 @@ export class SpokeHub {
|
|
|
669
672
|
}
|
|
670
673
|
|
|
671
674
|
/** Resolves or rejects the pending request a `res` frame answers. */
|
|
672
|
-
private settle(connID: string, message: { readonly id: string; readonly ok: boolean; readonly result?: unknown; readonly error?: string }): void {
|
|
675
|
+
private settle(connID: string, message: { readonly id: string; readonly ok: boolean; readonly result?: unknown; readonly error?: string; readonly code?: string }): void {
|
|
673
676
|
const pending = this.pending.get(message.id)
|
|
674
677
|
if (!pending || pending.connID !== connID) return
|
|
675
678
|
this.pending.delete(message.id)
|
|
@@ -683,7 +686,7 @@ export class SpokeHub {
|
|
|
683
686
|
return pending.resolve(message.result)
|
|
684
687
|
}
|
|
685
688
|
if (message.ok) pending.resolve(message.result)
|
|
686
|
-
else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed
|
|
689
|
+
else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`, message.code))
|
|
687
690
|
}
|
|
688
691
|
|
|
689
692
|
private async answer(ws: Socket, frame: Req<SpokeRequest>): Promise<void> {
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
// progress is reported explicitly through setStatus (POST /teams/:id/intake/:intakeID/status, MCP
|
|
26
26
|
// team_intake_update, the OpenCode hub tool's intake_update verb), which the submitter reads (GET, `team.intake`
|
|
27
27
|
// events). The text says so, with the exact verb and IDs (reportingLines).
|
|
28
|
+
// Hops (0.8.1): a session's submission continues its hop chain — the submitter's depth + 1 at submit time (hopOf)
|
|
29
|
+
// is kept on the row (never shown) and delivered as SendOptions.chainHop, so intake is not a way to restart a chain
|
|
30
|
+
// at hop 1; the user's and apps' submissions start their own.
|
|
28
31
|
//
|
|
29
32
|
// Status (explicit, never inferred from idleness/presence/departure; HWA-04/06): intake `queued` → `routed` →
|
|
30
33
|
// `done` | `failed` | `cancelled` (queued may be cancelled). While routed, `progress` mirrors the assignment:
|
|
@@ -143,6 +146,8 @@ export interface StoredIntake extends TeamIntake {
|
|
|
143
146
|
|
|
144
147
|
interface IntakeRow extends StoredIntake {
|
|
145
148
|
readonly claim?: { readonly owner: string; readonly at: number }
|
|
149
|
+
/** A session submitter's depth + 1 at submit time: the delivery continues its hop chain (never shown). */
|
|
150
|
+
readonly hop?: number
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
interface IntakeKey {
|
|
@@ -159,8 +164,14 @@ interface IntakeDoc {
|
|
|
159
164
|
readonly keys: Readonly<Record<string, IntakeKey>>
|
|
160
165
|
}
|
|
161
166
|
|
|
162
|
-
/**
|
|
163
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Delivers one task text to a session (production: Delivery.send as intakeActor); `hop`: the chain it continues (a
|
|
169
|
+
* submitting session's depth + 1; production passes it as SendOptions.chainHop).
|
|
170
|
+
*/
|
|
171
|
+
export type IntakeDeliver = (userID: string, sessionID: string, text: string, options?: { readonly hop?: number }) => Promise<{ readonly messageID: string }>
|
|
172
|
+
|
|
173
|
+
/** The hop a message from the session would carry now (production: Delivery.hops.next). */
|
|
174
|
+
export type IntakeHopOf = (userID: string, sessionID: string) => Promise<number>
|
|
164
175
|
|
|
165
176
|
export interface TeamIntakeDeps {
|
|
166
177
|
readonly state: StateStore
|
|
@@ -170,6 +181,8 @@ export interface TeamIntakeDeps {
|
|
|
170
181
|
readonly members: Pick<MembershipStore, "members" | "assign" | "assignment" | "setStatus" | "reassign">
|
|
171
182
|
readonly presence: SessionPresenceLookup
|
|
172
183
|
readonly deliver: IntakeDeliver
|
|
184
|
+
/** A session submitter's hop chain (absent: deliveries start their own chain). */
|
|
185
|
+
readonly hopOf?: IntakeHopOf
|
|
173
186
|
readonly events?: Pick<AgentEvents, "append">
|
|
174
187
|
readonly report?: (cause: unknown) => void
|
|
175
188
|
readonly now?: Clock
|
|
@@ -239,9 +252,9 @@ const sameSource = (row: StoredIntake, actor: AgentActor): boolean =>
|
|
|
239
252
|
(actor.kind === "session" && row.source.kind === "session" && row.source.sessionID === actor.sessionID) ||
|
|
240
253
|
(actor.kind === "user" && row.source.kind === "user")
|
|
241
254
|
|
|
242
|
-
/** The stored row as reads show it (no routing claim). */
|
|
255
|
+
/** The stored row as reads show it (no routing claim, no hop). */
|
|
243
256
|
function view(row: IntakeRow): StoredIntake {
|
|
244
|
-
const { claim: _claim, ...rest } = row
|
|
257
|
+
const { claim: _claim, hop: _hop, ...rest } = row
|
|
245
258
|
return rest
|
|
246
259
|
}
|
|
247
260
|
|
|
@@ -308,6 +321,7 @@ export class TeamIntakeService {
|
|
|
308
321
|
const digest = digestOf(body)
|
|
309
322
|
const slot = key === undefined ? undefined : `${partyOf(actor)}|${key}`
|
|
310
323
|
const id = this.newID("intake")
|
|
324
|
+
const hop = actor.kind === "session" && this.deps.hopOf ? await this.deps.hopOf(userID, actor.sessionID) : undefined
|
|
311
325
|
if (body.brief !== undefined) await this.state.put(userID, TEAM_INTAKE_BRIEFS, id, { v: 1, teamID, brief: body.brief })
|
|
312
326
|
let result: { row: IntakeRow; created: boolean } | undefined
|
|
313
327
|
try {
|
|
@@ -331,7 +345,7 @@ export class TeamIntakeService {
|
|
|
331
345
|
if (doc.items.filter((i) => !CLOSED.has(i.status)).length >= this.limits.openPerTeam)
|
|
332
346
|
throw new AgentError("rate_limited", `A team holds at most ${this.limits.openPerTeam} open submissions`, { hint: "Close or cancel some first", extra: { limit: this.limits.openPerTeam } })
|
|
333
347
|
const { brief: _brief, ...fields } = body
|
|
334
|
-
const row: IntakeRow = { v: 1, id, teamID, ...fields, source: sourceOf(actor), status: "queued", revision: 1, createdAt: at, updatedAt: at }
|
|
348
|
+
const row: IntakeRow = { v: 1, id, teamID, ...fields, source: sourceOf(actor), status: "queued", revision: 1, createdAt: at, updatedAt: at, ...(hop === undefined ? {} : { hop }) }
|
|
335
349
|
result = { row, created: true }
|
|
336
350
|
const nextKeys = slot === undefined ? keys : { ...keys, [slot]: { intakeID: id, digest, at } }
|
|
337
351
|
return { ...doc, items: this.retained([...doc.items, row]), keys: this.boundedKeys(nextKeys) }
|
|
@@ -540,7 +554,7 @@ export class TeamIntakeService {
|
|
|
540
554
|
await this.mutate(userID, team.id, intakeID, (row) => ({ ...row, assignmentID: assignment.id, workspaceID: target.workspaceID }), false)
|
|
541
555
|
const brief = (await this.state.get<{ brief?: unknown }>(userID, TEAM_INTAKE_BRIEFS, intakeID))?.value?.brief
|
|
542
556
|
const text = this.text(team, claimed, assignment.id, target, typeof brief === "string" ? brief : undefined)
|
|
543
|
-
const sent = await this.deps.deliver(userID, target.sessionID, text)
|
|
557
|
+
const sent = await this.deps.deliver(userID, target.sessionID, text, claimed.hop === undefined ? {} : { hop: claimed.hop })
|
|
544
558
|
let cancelled = false
|
|
545
559
|
let routed: IntakeRow | undefined
|
|
546
560
|
await this.writeDoc(userID, team.id, (doc) => {
|
|
@@ -219,8 +219,15 @@ export interface TeamStaffingDeps {
|
|
|
219
219
|
readonly report?: (cause: unknown) => void
|
|
220
220
|
readonly now?: Clock
|
|
221
221
|
readonly newID?: (kind: IdKind) => string
|
|
222
|
+
/**
|
|
223
|
+
* (FX-07) Whether the session is one of the user's Concierge sessions (current or past). Such a session is never
|
|
224
|
+
* staffed (attach, replace, a team launch), for anyone: the hub manages it. The routes guard this too.
|
|
225
|
+
*/
|
|
226
|
+
readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
|
|
222
227
|
}
|
|
223
228
|
|
|
229
|
+
const conciergeRefusal = () => new AgentError("forbidden", "This does not apply to the Concierge: the hub manages it", { extra: { policy: "concierge" } })
|
|
230
|
+
|
|
224
231
|
interface Need {
|
|
225
232
|
readonly verb: "launch" | "roles"
|
|
226
233
|
readonly directory: string
|
|
@@ -540,7 +547,7 @@ export class TeamStaffingService {
|
|
|
540
547
|
/** The launch body: the caller's, with the role's placement/harness/model/profile defaults and team labels. */
|
|
541
548
|
private async launchSpec(userID: string, team: Team, role: TeamRoleSpec, base: unknown, requestID: string): Promise<Record<string, unknown>> {
|
|
542
549
|
if (!isObj(base)) throw new AgentError("invalid", "launch must be a launch body (workspace, prompt, …)")
|
|
543
|
-
if (base.key !== undefined) throw new AgentError("invalid", "launch.key is set by staffing", { hint: "Send
|
|
550
|
+
if (base.key !== undefined) throw new AgentError("invalid", "launch.key is set by staffing", { hint: "Send idempotencyKey (the Idempotency-Key header) instead" })
|
|
544
551
|
const labels = isObj(base.labels) ? base.labels : base.labels === undefined ? {} : undefined
|
|
545
552
|
if (!labels) throw new AgentError("invalid", "launch.labels must be an object")
|
|
546
553
|
if (TEAM_LAUNCH_LABEL in labels || TEAM_REQUEST_LABEL in labels) throw new AgentError("invalid", `launch.labels ${TEAM_LAUNCH_LABEL} and ${TEAM_REQUEST_LABEL} are set by staffing`)
|
|
@@ -560,7 +567,7 @@ export class TeamStaffingService {
|
|
|
560
567
|
...(agentProfile !== undefined ? { agentProfile } : {}),
|
|
561
568
|
labels: { ...labels, [TEAM_LAUNCH_LABEL]: team.id, [TEAM_REQUEST_LABEL]: requestID },
|
|
562
569
|
}
|
|
563
|
-
validateLaunchSpec(spec)
|
|
570
|
+
validateLaunchSpec(spec, { staffing: true })
|
|
564
571
|
return spec
|
|
565
572
|
}
|
|
566
573
|
|
|
@@ -604,6 +611,7 @@ export class TeamStaffingService {
|
|
|
604
611
|
let staffing: TeamSeatRecord
|
|
605
612
|
let created: boolean
|
|
606
613
|
try {
|
|
614
|
+
await this.notConcierge(userID, sessionID)
|
|
607
615
|
const team = await this.deps.teams.require(userID, request.teamID)
|
|
608
616
|
if (request.replaces) {
|
|
609
617
|
staffable(team)
|
|
@@ -800,9 +808,23 @@ export class TeamStaffingService {
|
|
|
800
808
|
if (typeof sessionID !== "string" || sessionID.length === 0 || sessionID.length > 200) throw new AgentError("invalid", "sessionID must be a session ID")
|
|
801
809
|
const presence = await this.deps.presence(userID, sessionID)
|
|
802
810
|
if (presence === "unknown" || presence === "deleted") throw new AgentError("not_found", `No session ${sessionID}`)
|
|
811
|
+
await this.notConcierge(userID, sessionID)
|
|
803
812
|
return sessionID
|
|
804
813
|
}
|
|
805
814
|
|
|
815
|
+
/**
|
|
816
|
+
* (FX-07) Refuses a Concierge session. A failed identity read refuses too (fail closed): a timed-out one with its own
|
|
817
|
+
* `busy` (503), any other as the Concierge refusal.
|
|
818
|
+
*/
|
|
819
|
+
private async notConcierge(userID: string, sessionID: string): Promise<void> {
|
|
820
|
+
if (!this.deps.protects) return
|
|
821
|
+
const concierge = await this.deps.protects(userID, sessionID).catch((cause: unknown) => {
|
|
822
|
+
if (cause instanceof AgentError && cause.code === "busy") throw cause
|
|
823
|
+
return true
|
|
824
|
+
})
|
|
825
|
+
if (concierge) throw conciergeRefusal()
|
|
826
|
+
}
|
|
827
|
+
|
|
806
828
|
/** The session's project for a grant check; offline when no spoke reports it (as role writes answer). */
|
|
807
829
|
private directoryOf(userID: string, sessionID: string): string {
|
|
808
830
|
const directory = this.deps.sessionDirectory(userID, sessionID)
|
|
@@ -223,7 +223,7 @@ export function sideChatRoutes(deps: SideChatRoutesDeps): AgentRoute[] {
|
|
|
223
223
|
snapshot: full ? snapshot : summary,
|
|
224
224
|
status,
|
|
225
225
|
...(launch
|
|
226
|
-
? { launch: { id: launch.id, state: launch.state, ...(launch.outcome ? { outcome: launch.outcome } : {}), ...(context.actor.kind === "user"
|
|
226
|
+
? { launch: { id: launch.id, state: launch.state, ...(launch.outcome ? { outcome: launch.outcome } : {}), ...(context.actor.kind === "user" ? { reason: launch.reason } : launch.by === launchedBy(context.actor) ? { reason: (launch.state === "queued" ? launch.waiting?.shared : undefined) ?? launch.reason } : {}) } }
|
|
227
227
|
: {}),
|
|
228
228
|
...(updatedAt !== undefined ? { sourceUpdatedAt: updatedAt, stale: updatedAt > snapshot.takenAt } : {}),
|
|
229
229
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,25 +14,31 @@ import { defineVoiceAgent, orchestratorParts, STOP_TOOL, STOP_TOOL_DESCRIPTION }
|
|
|
14
14
|
import { defineConciergeAgent } from "./spoke/concierge-agent"
|
|
15
15
|
|
|
16
16
|
import { CONCIERGE_AGENT, CONCIERGE_TOOLS } from "../packages/protocol/src/concierge.ts"
|
|
17
|
-
import { envInjector, hubBinDir, ShellCorrelator, stripInheritedHubEnv } from "./spoke/env-inject"
|
|
17
|
+
import { defaultHubBinDir, envInjector, hubBinDir, ShellCorrelator, stripInheritedHubEnv } from "./spoke/env-inject"
|
|
18
18
|
import { herdrRunner } from "./spoke/herdr"
|
|
19
19
|
import { launchSource } from "./spoke/launch-source"
|
|
20
20
|
import { HubRpcError, hubMsg, hubWho } from "./spoke/hub-rpc"
|
|
21
21
|
import { HUB_TOOL, HUB_TOOL_DESCRIPTION, HUB_TOOL_INPUT, runHubTool } from "./spoke/hub-tool"
|
|
22
22
|
import { cliEntry, installLaunchers, linkUserBin, standaloneHubctl } from "./spoke/launchers"
|
|
23
23
|
import { listServiceSessions } from "./spoke/listing"
|
|
24
|
-
import {
|
|
24
|
+
import { conciergeLockFiles, ensureLocalHub } from "./spoke/localhub"
|
|
25
|
+
import { hintStateDirOnce, resolveHubStateDir } from "./migrate"
|
|
25
26
|
import path from "node:path"
|
|
26
27
|
import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
|
|
27
28
|
import { PromptReferences } from "./spoke/prompt-refs"
|
|
28
29
|
import { PROFILE_TOOL, PROFILE_TOOL_DESCRIPTION, PROFILE_TOOL_INPUT } from "./spoke/profile-apply"
|
|
29
|
-
import { replyPermission, type PermissionLocation } from "./spoke/locations"
|
|
30
|
+
import { listedNames, namesReader, replyPermission, serverNames, vcsReader, type PermissionLocation, type VcsReader } from "./spoke/locations"
|
|
30
31
|
import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
|
|
31
32
|
import { joinProcess, type Membership } from "./spoke/singleton"
|
|
33
|
+
import { applySessionContext, sortTools } from "./spoke/session-context"
|
|
32
34
|
import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
|
|
33
35
|
|
|
34
36
|
/** Where the spoke keeps the IDs of Concierge sessions (always restricted to the Concierge's tools). */
|
|
35
|
-
|
|
37
|
+
/** Written in the state dir; also read from the other default places, so a lock from before an upgrade still holds. */
|
|
38
|
+
const conciergeLocks = () => {
|
|
39
|
+
const { file, read } = conciergeLockFiles()
|
|
40
|
+
return { conciergeLockFile: file, conciergeLockReadFiles: read }
|
|
41
|
+
}
|
|
36
42
|
|
|
37
43
|
type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
|
|
38
44
|
|
|
@@ -67,7 +73,7 @@ function pluginVersion(): string {
|
|
|
67
73
|
* its own server, only the sessions of the locations it loaded (`scope`), so idle sessions of a loaded project are
|
|
68
74
|
* online without having run in this process, and nothing of other projects is claimed.
|
|
69
75
|
*/
|
|
70
|
-
async function listRecent(kind: "service" | "standalone", scope: () => string[], options?: { days: number; max: number; include: readonly string[] }) {
|
|
76
|
+
async function listRecent(kind: "service" | "standalone", scope: () => string[], options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) {
|
|
71
77
|
if (kind === "service") {
|
|
72
78
|
const { Service } = await import("@opencode/client/service")
|
|
73
79
|
const { OpenCode } = await import("@opencode/client")
|
|
@@ -165,13 +171,42 @@ function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
|
|
|
165
171
|
/** This instance's location-scoped permission API (OpenCode keeps pending permissions per location). */
|
|
166
172
|
function permissionLocation(ctx: Context): PermissionLocation {
|
|
167
173
|
const permission = ctx.permission as any
|
|
174
|
+
const vcs = (ctx as any).vcs
|
|
175
|
+
const skill = (ctx as any).skill
|
|
176
|
+
const command = (ctx as any).command
|
|
168
177
|
return {
|
|
169
178
|
directory: ctx.location.directory,
|
|
170
179
|
get: (input) => permission.get(input),
|
|
171
180
|
reply: (input) => permission.reply(input),
|
|
181
|
+
// OpenCode's plugin vcs, skill and command lists answer for this instance's location only (whatever location is passed).
|
|
182
|
+
...(vcs?.diff && vcs?.get ? { vcs: vcsOf(vcs, ctx.location.directory) } : {}),
|
|
183
|
+
...(skill?.list && command?.list ? { names: () => localNamesOf(skill, command, ctx.location.directory) } : {}),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A plugin instance's own location's skill and command names (its lists answer only for that location). */
|
|
188
|
+
async function localNamesOf(skill: any, command: any, directory: string) {
|
|
189
|
+
const [skills, commands] = await Promise.all([skill.list({ location: { directory } }), command.list({ location: { directory } })])
|
|
190
|
+
return { skills: listedNames(skills), commands: listedNames(commands) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A vcs API's working-copy changes and current branch of a directory (plugin API or own-server client). */
|
|
194
|
+
function vcsOf(vcs: any, directory: string) {
|
|
195
|
+
return {
|
|
196
|
+
diff: async () => unwrap<ReadonlyArray<unknown>>(await vcs.diff({ mode: "working", location: { directory } }), Array.isArray),
|
|
197
|
+
branch: async () => {
|
|
198
|
+
const info = unwrap<{ branch?: { current?: string } }>(await vcs.get({ location: { directory } }), (v) => "branch" in v)
|
|
199
|
+
return typeof info?.branch?.current === "string" ? info.branch.current : undefined
|
|
200
|
+
},
|
|
172
201
|
}
|
|
173
202
|
}
|
|
174
203
|
|
|
204
|
+
/** The own server's vcs API (it honours the location), when the server can be reached. */
|
|
205
|
+
function serverVcs(client: { vcs?: unknown } | undefined): VcsReader | undefined {
|
|
206
|
+
if (!client?.vcs) return undefined
|
|
207
|
+
return { diff: (directory) => vcsOf(client.vcs, directory).diff(), branch: (directory) => vcsOf(client.vcs, directory).branch() }
|
|
208
|
+
}
|
|
209
|
+
|
|
175
210
|
/** `locations`: the permission APIs of every plugin instance in this process (one per location). */
|
|
176
211
|
function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "service" | "standalone"): SpokeHost {
|
|
177
212
|
const session = ctx.session as any
|
|
@@ -180,7 +215,11 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
180
215
|
const vcs = (ctx as any).vcs
|
|
181
216
|
const skill = (ctx as any).skill
|
|
182
217
|
const command = (ctx as any).command
|
|
183
|
-
const
|
|
218
|
+
const vcsRead = vcsReader(locations, async () => (canList(kind) ? serverVcs(await ownClient(kind).catch(() => undefined)) : undefined))
|
|
219
|
+
const namesRead = namesReader(locations, async () => {
|
|
220
|
+
const client = canList(kind) ? await ownClient(kind).catch(() => undefined) : undefined
|
|
221
|
+
return client?.skill && client?.command ? serverNames(client) : undefined
|
|
222
|
+
})
|
|
184
223
|
return {
|
|
185
224
|
directory: ctx.location.directory,
|
|
186
225
|
opencodeVersion: ctx.app?.version ?? "unknown",
|
|
@@ -212,7 +251,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
212
251
|
const list = unwrap<Array<{ id: string; mode?: string; hidden?: boolean }>>(await agent.list({}), Array.isArray)
|
|
213
252
|
return list?.find((a) => a.mode !== "subagent" && !a.hidden)?.id
|
|
214
253
|
},
|
|
215
|
-
...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[] }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
|
|
254
|
+
...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
|
|
216
255
|
activity: (directories) => activity(kind, directories),
|
|
217
256
|
// Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
|
|
218
257
|
...(canList(kind)
|
|
@@ -227,24 +266,12 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
227
266
|
unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
|
|
228
267
|
...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
|
|
229
268
|
// Hub profiles (src/spoke/profile-apply.ts): local skills/commands win over a profile's; its own need the tool.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
: {}),
|
|
269
|
+
// Read in the session's own directory (src/spoke/locations.ts); unknown (undefined): the profile's skills and
|
|
270
|
+
// commands are not offered, never checked against another folder's names.
|
|
271
|
+
...((skill?.list && command?.list) || canList(kind) ? { localNames: (directory: string) => namesRead(directory) } : {}),
|
|
238
272
|
profileTool: typeof (ctx as any).tool?.transform === "function",
|
|
239
|
-
|
|
240
|
-
...(vcs?.
|
|
241
|
-
? {
|
|
242
|
-
vcsBranch: async (directory: string) => {
|
|
243
|
-
const info = unwrap<{ branch?: { current?: string } }>(await vcs.get({ location: { directory } }), (v) => "branch" in v)
|
|
244
|
-
return typeof info?.branch?.current === "string" ? info.branch.current : undefined
|
|
245
|
-
},
|
|
246
|
-
}
|
|
247
|
-
: {}),
|
|
273
|
+
// A directory's branch and changes: its own plugin instance, else the own server (src/spoke/locations.ts).
|
|
274
|
+
...(vcs?.diff || canList(kind) ? { vcsDiff: (directory: string) => vcsRead.diff(directory), vcsBranch: (directory: string) => vcsRead.branch(directory) } : {}),
|
|
248
275
|
}
|
|
249
276
|
}
|
|
250
277
|
|
|
@@ -254,6 +281,8 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
254
281
|
*/
|
|
255
282
|
async function registerShellEnv(ctx: Context, identity: (sessionID: string) => { token: string; url?: string } | undefined) {
|
|
256
283
|
const binDir = hubBinDir()
|
|
284
|
+
// XDG_STATE_HOME set while the existing ~/.local/state/hub is kept: one line saying so, with the mv to switch.
|
|
285
|
+
hintStateDirOnce(resolveHubStateDir(process.env, homedir()))
|
|
257
286
|
try {
|
|
258
287
|
mkdirSync(binDir, { recursive: true, mode: 0o700 })
|
|
259
288
|
// The `hub`/`hubctl` launchers run the CLI with this runtime (OpenCode's binary as bun; src/spoke/launchers.ts).
|
|
@@ -262,7 +291,7 @@ async function registerShellEnv(ctx: Context, identity: (sessionID: string) => {
|
|
|
262
291
|
if (entry) {
|
|
263
292
|
installLaunchers({ binDir, execPath: process.execPath, bun, entry, standalone: standaloneHubctl(homedir()) })
|
|
264
293
|
// `hub` in the user's own terminals too (~/.local/bin/hub, never over anything of theirs).
|
|
265
|
-
const { hint } = linkUserBin({ home: homedir(), binDir, path: process.env.PATH })
|
|
294
|
+
const { hint } = linkUserBin({ home: homedir(), binDir, path: process.env.PATH, defaultBinDir: defaultHubBinDir() })
|
|
266
295
|
if (hint) console.error(`[hub] ${hint}`)
|
|
267
296
|
} else console.error("[hub] no built hub CLI (packages/cli/dist/hub-cli.js): `hub` is not installed")
|
|
268
297
|
} catch (cause) {
|
|
@@ -297,13 +326,13 @@ export default definePlugin({
|
|
|
297
326
|
const common = { machineName: hostname(), kind, processID, pid: process.pid, pluginVersion: pluginVersion() }
|
|
298
327
|
if (source.kind === "launch") {
|
|
299
328
|
const nodeConfig = source.nodeConfig
|
|
300
|
-
return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig),
|
|
329
|
+
return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig), ...conciergeLocks() })
|
|
301
330
|
}
|
|
302
331
|
return new Spoke(host, {
|
|
303
332
|
...common,
|
|
304
333
|
configFile: prepareConfigPath(),
|
|
305
334
|
// Concierge session locks survive a restart before the hub reconnects (src/spoke/session-context.ts).
|
|
306
|
-
|
|
335
|
+
...conciergeLocks(),
|
|
307
336
|
// Loopback servers only, and only when autoStartLocalHub is on (see src/spoke/localhub.ts).
|
|
308
337
|
localHub: (server) => ensureLocalHub(server),
|
|
309
338
|
// One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts). Launch creates
|
|
@@ -343,12 +372,16 @@ export default definePlugin({
|
|
|
343
372
|
// A hub profile: after OpenCode's own parts (local instructions win), for this session only.
|
|
344
373
|
for (const text of spoke?.profiles.systemParts(event.sessionID) ?? []) event.system.push({ type: "text", text })
|
|
345
374
|
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
|
|
347
|
-
|
|
375
|
+
// (CON-04) The hub's session context: its stable parts after the profile's, its board after the conversation
|
|
376
|
+
// (V-01: the prompt cache survives a board change), its denied tools removed, the kept tools in name order.
|
|
377
|
+
if (spoke) applySessionContext(event as any, spoke.contexts.systemParts(event.sessionID), spoke.contexts.tailParts(event.sessionID))
|
|
348
378
|
if (event.tools) spoke?.contexts.filterTools(event.sessionID, event.tools as Record<string, unknown>)
|
|
349
379
|
// The concierge agent keeps exactly CONCIERGE_TOOLS even without a hub context (a spoke that restarted before
|
|
350
380
|
// 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)
|
|
381
|
+
if (event.tools && event.agent === CONCIERGE_AGENT) {
|
|
382
|
+
for (const name of Object.keys(event.tools)) if (!(CONCIERGE_TOOLS as readonly string[]).includes(name)) delete event.tools[name]
|
|
383
|
+
sortTools(event.tools as Record<string, unknown>)
|
|
384
|
+
}
|
|
352
385
|
}),
|
|
353
386
|
),
|
|
354
387
|
// The voice agent stops one worker ("cancel the deploy one") without stopping itself or the others.
|