@huynle/hub 0.8.0 → 0.8.1
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 +213 -1
- package/README.md +84 -15
- 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 +649 -242
- package/packages/cli/package.json +1 -1
- 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 +7 -3
- 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/teams-mcp.ts +59 -2
- 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/index.ts +7 -1
- package/packages/protocol/src/launch.ts +14 -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/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/src/central/agent/acting.ts +3 -0
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +101 -27
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +10 -2
- 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 +26 -2
- 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 +4 -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/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +4 -3
- 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 +38 -15
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +26 -1
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +22 -0
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +39 -18
- package/src/migrate.ts +109 -3
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/hub-tool.ts +24 -10
- 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 +40 -0
- package/src/spoke/session-context.ts +107 -27
- package/src/spoke/spoke.ts +13 -5
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.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-DAr4IHM8.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-DWZj595u.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-JUb6OeI3.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
|
@@ -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
|
|
@@ -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,30 @@ 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 { replyPermission, 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"
|
|
32
33
|
import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
|
|
33
34
|
|
|
34
35
|
/** Where the spoke keeps the IDs of Concierge sessions (always restricted to the Concierge's tools). */
|
|
35
|
-
|
|
36
|
+
/** Written in the state dir; also read from the other default places, so a lock from before an upgrade still holds. */
|
|
37
|
+
const conciergeLocks = () => {
|
|
38
|
+
const { file, read } = conciergeLockFiles()
|
|
39
|
+
return { conciergeLockFile: file, conciergeLockReadFiles: read }
|
|
40
|
+
}
|
|
36
41
|
|
|
37
42
|
type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
|
|
38
43
|
|
|
@@ -67,7 +72,7 @@ function pluginVersion(): string {
|
|
|
67
72
|
* its own server, only the sessions of the locations it loaded (`scope`), so idle sessions of a loaded project are
|
|
68
73
|
* online without having run in this process, and nothing of other projects is claimed.
|
|
69
74
|
*/
|
|
70
|
-
async function listRecent(kind: "service" | "standalone", scope: () => string[], options?: { days: number; max: number; include: readonly string[] }) {
|
|
75
|
+
async function listRecent(kind: "service" | "standalone", scope: () => string[], options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) {
|
|
71
76
|
if (kind === "service") {
|
|
72
77
|
const { Service } = await import("@opencode/client/service")
|
|
73
78
|
const { OpenCode } = await import("@opencode/client")
|
|
@@ -165,13 +170,33 @@ function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
|
|
|
165
170
|
/** This instance's location-scoped permission API (OpenCode keeps pending permissions per location). */
|
|
166
171
|
function permissionLocation(ctx: Context): PermissionLocation {
|
|
167
172
|
const permission = ctx.permission as any
|
|
173
|
+
const vcs = (ctx as any).vcs
|
|
168
174
|
return {
|
|
169
175
|
directory: ctx.location.directory,
|
|
170
176
|
get: (input) => permission.get(input),
|
|
171
177
|
reply: (input) => permission.reply(input),
|
|
178
|
+
// OpenCode's plugin vcs answers for this instance's location only (whatever location is passed).
|
|
179
|
+
...(vcs?.diff && vcs?.get ? { vcs: vcsOf(vcs, ctx.location.directory) } : {}),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A vcs API's working-copy changes and current branch of a directory (plugin API or own-server client). */
|
|
184
|
+
function vcsOf(vcs: any, directory: string) {
|
|
185
|
+
return {
|
|
186
|
+
diff: async () => unwrap<ReadonlyArray<unknown>>(await vcs.diff({ mode: "working", location: { directory } }), Array.isArray),
|
|
187
|
+
branch: async () => {
|
|
188
|
+
const info = unwrap<{ branch?: { current?: string } }>(await vcs.get({ location: { directory } }), (v) => "branch" in v)
|
|
189
|
+
return typeof info?.branch?.current === "string" ? info.branch.current : undefined
|
|
190
|
+
},
|
|
172
191
|
}
|
|
173
192
|
}
|
|
174
193
|
|
|
194
|
+
/** The own server's vcs API (it honours the location), when the server can be reached. */
|
|
195
|
+
function serverVcs(client: { vcs?: unknown } | undefined): VcsReader | undefined {
|
|
196
|
+
if (!client?.vcs) return undefined
|
|
197
|
+
return { diff: (directory) => vcsOf(client.vcs, directory).diff(), branch: (directory) => vcsOf(client.vcs, directory).branch() }
|
|
198
|
+
}
|
|
199
|
+
|
|
175
200
|
/** `locations`: the permission APIs of every plugin instance in this process (one per location). */
|
|
176
201
|
function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "service" | "standalone"): SpokeHost {
|
|
177
202
|
const session = ctx.session as any
|
|
@@ -180,6 +205,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
180
205
|
const vcs = (ctx as any).vcs
|
|
181
206
|
const skill = (ctx as any).skill
|
|
182
207
|
const command = (ctx as any).command
|
|
208
|
+
const vcsRead = vcsReader(locations, async () => (canList(kind) ? serverVcs(await ownClient(kind).catch(() => undefined)) : undefined))
|
|
183
209
|
const names = (value: unknown) => (unwrap<ReadonlyArray<{ name?: unknown }>>(value, Array.isArray) ?? []).flatMap((item) => (typeof item?.name === "string" ? [item.name] : []))
|
|
184
210
|
return {
|
|
185
211
|
directory: ctx.location.directory,
|
|
@@ -212,7 +238,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
212
238
|
const list = unwrap<Array<{ id: string; mode?: string; hidden?: boolean }>>(await agent.list({}), Array.isArray)
|
|
213
239
|
return list?.find((a) => a.mode !== "subagent" && !a.hidden)?.id
|
|
214
240
|
},
|
|
215
|
-
...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[] }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
|
|
241
|
+
...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
|
|
216
242
|
activity: (directories) => activity(kind, directories),
|
|
217
243
|
// Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
|
|
218
244
|
...(canList(kind)
|
|
@@ -236,15 +262,8 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
236
262
|
}
|
|
237
263
|
: {}),
|
|
238
264
|
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
|
-
: {}),
|
|
265
|
+
// A directory's branch and changes: its own plugin instance, else the own server (src/spoke/locations.ts).
|
|
266
|
+
...(vcs?.diff || canList(kind) ? { vcsDiff: (directory: string) => vcsRead.diff(directory), vcsBranch: (directory: string) => vcsRead.branch(directory) } : {}),
|
|
248
267
|
}
|
|
249
268
|
}
|
|
250
269
|
|
|
@@ -254,6 +273,8 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
254
273
|
*/
|
|
255
274
|
async function registerShellEnv(ctx: Context, identity: (sessionID: string) => { token: string; url?: string } | undefined) {
|
|
256
275
|
const binDir = hubBinDir()
|
|
276
|
+
// XDG_STATE_HOME set while the existing ~/.local/state/hub is kept: one line saying so, with the mv to switch.
|
|
277
|
+
hintStateDirOnce(resolveHubStateDir(process.env, homedir()))
|
|
257
278
|
try {
|
|
258
279
|
mkdirSync(binDir, { recursive: true, mode: 0o700 })
|
|
259
280
|
// The `hub`/`hubctl` launchers run the CLI with this runtime (OpenCode's binary as bun; src/spoke/launchers.ts).
|
|
@@ -262,7 +283,7 @@ async function registerShellEnv(ctx: Context, identity: (sessionID: string) => {
|
|
|
262
283
|
if (entry) {
|
|
263
284
|
installLaunchers({ binDir, execPath: process.execPath, bun, entry, standalone: standaloneHubctl(homedir()) })
|
|
264
285
|
// `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 })
|
|
286
|
+
const { hint } = linkUserBin({ home: homedir(), binDir, path: process.env.PATH, defaultBinDir: defaultHubBinDir() })
|
|
266
287
|
if (hint) console.error(`[hub] ${hint}`)
|
|
267
288
|
} else console.error("[hub] no built hub CLI (packages/cli/dist/hub-cli.js): `hub` is not installed")
|
|
268
289
|
} catch (cause) {
|
|
@@ -297,13 +318,13 @@ export default definePlugin({
|
|
|
297
318
|
const common = { machineName: hostname(), kind, processID, pid: process.pid, pluginVersion: pluginVersion() }
|
|
298
319
|
if (source.kind === "launch") {
|
|
299
320
|
const nodeConfig = source.nodeConfig
|
|
300
|
-
return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig),
|
|
321
|
+
return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig), ...conciergeLocks() })
|
|
301
322
|
}
|
|
302
323
|
return new Spoke(host, {
|
|
303
324
|
...common,
|
|
304
325
|
configFile: prepareConfigPath(),
|
|
305
326
|
// Concierge session locks survive a restart before the hub reconnects (src/spoke/session-context.ts).
|
|
306
|
-
|
|
327
|
+
...conciergeLocks(),
|
|
307
328
|
// Loopback servers only, and only when autoStartLocalHub is on (see src/spoke/localhub.ts).
|
|
308
329
|
localHub: (server) => ensureLocalHub(server),
|
|
309
330
|
// One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts). Launch creates
|
package/src/migrate.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
symlinkSync,
|
|
17
17
|
} from "node:fs"
|
|
18
18
|
import path from "node:path"
|
|
19
|
-
import type
|
|
19
|
+
import { hubEnv, type Env } from "./env"
|
|
20
20
|
|
|
21
21
|
export const APP_DIR = "hub"
|
|
22
22
|
/** Legacy directory names under ~/.config and ~/.local/state, most recent first. */
|
|
@@ -24,8 +24,81 @@ export const LEGACY_APP_DIRS: readonly string[] = Object.freeze(["opencode-hub",
|
|
|
24
24
|
|
|
25
25
|
/** $XDG_CONFIG_HOME or ~/.config. */
|
|
26
26
|
export const configBase = (env: Env, home: string): string => env.XDG_CONFIG_HOME || path.join(home, ".config")
|
|
27
|
-
/**
|
|
28
|
-
export const
|
|
27
|
+
/** $XDG_STATE_HOME when it is an absolute path (the XDG spec: a relative one is invalid and ignored), else undefined. */
|
|
28
|
+
export const xdgStateHome = (env: Env): string | undefined => (env.XDG_STATE_HOME && path.isAbsolute(env.XDG_STATE_HOME) ? env.XDG_STATE_HOME : undefined)
|
|
29
|
+
|
|
30
|
+
/** $XDG_STATE_HOME or ~/.local/state. The hub's own state dir is resolveHubStateDir (sticky), not this. */
|
|
31
|
+
export const stateBase = (env: Env, home: string): string => xdgStateHome(env) ?? path.join(home, ".local", "state")
|
|
32
|
+
|
|
33
|
+
export interface HubStateDir {
|
|
34
|
+
readonly dir: string
|
|
35
|
+
/** env: HUB_STATE_DIR; xdg: an existing $XDG_STATE_HOME/hub; legacy: an existing ~/.local/state/hub kept although
|
|
36
|
+
* XDG_STATE_HOME is set; fresh: a new $XDG_STATE_HOME/hub; default: ~/.local/state/hub without XDG_STATE_HOME. */
|
|
37
|
+
readonly source: "env" | "xdg" | "legacy" | "fresh" | "default"
|
|
38
|
+
/** legacy only: one line for the log at startup (XDG_STATE_HOME is set, the existing dir is used, how to switch). */
|
|
39
|
+
readonly hint?: string
|
|
40
|
+
/** `dir`, then the other default places (~/.local/state/hub, $XDG_STATE_HOME/hub) it is not: the Concierge lock is read from all. */
|
|
41
|
+
readonly candidates: readonly string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the hub writes in its state dir (the hub server's state.json, token-keys.json, agent/ and blobs/; the plugin's
|
|
46
|
+
* launchers bin/ and Concierge lock; the local hub's hub.log / hub.pid; the Claude Code and Codex spokes' dirs). A
|
|
47
|
+
* dir holding none of them (empty, or only `hubctl setup`'s bootstrap/) does not count as an existing state dir.
|
|
48
|
+
*/
|
|
49
|
+
export const HUB_STATE_MARKERS: readonly string[] = Object.freeze(["state.json", "token-keys.json", "agent", "blobs", "concierge-sessions.json", "bin", "hub.log", "hub.pid", "claude-code", "codex"])
|
|
50
|
+
|
|
51
|
+
/** Whether `dir` holds hub state (any of HUB_STATE_MARKERS). */
|
|
52
|
+
export const holdsHubState = (dir: string, exists: (p: string) => boolean = existsSync): boolean => HUB_STATE_MARKERS.some((marker) => exists(path.join(dir, marker)))
|
|
53
|
+
|
|
54
|
+
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The hub state dir, "sticky" (FX-08): $HUB_STATE_DIR; else $XDG_STATE_HOME/hub when XDG_STATE_HOME is set and that
|
|
58
|
+
* dir holds hub state (HUB_STATE_MARKERS; `exists` is asked about each marker); else an existing ~/.local/state/hub (kept, never copied or moved; with a hint when XDG_STATE_HOME is
|
|
59
|
+
* set); else $XDG_STATE_HOME/hub when it is set (fresh installs); else ~/.local/state/hub. Every part of hub (plugin,
|
|
60
|
+
* local hub, hub server, the Claude Code and Codex spokes) uses this, so a shell and a service unit agree unless one
|
|
61
|
+
* sets HUB_STATE_DIR.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveHubStateDir(env: Env, home: string, exists: (p: string) => boolean = existsSync): HubStateDir {
|
|
64
|
+
const legacy = path.join(home, ".local", "state", APP_DIR)
|
|
65
|
+
const xdg = xdgStateHome(env)
|
|
66
|
+
const xdgHub = xdg ? path.join(xdg, APP_DIR) : undefined
|
|
67
|
+
const defaults = [legacy, ...(xdgHub && xdgHub !== legacy ? [xdgHub] : [])]
|
|
68
|
+
const result = (dir: string, source: HubStateDir["source"], hint?: string): HubStateDir => ({
|
|
69
|
+
dir,
|
|
70
|
+
source,
|
|
71
|
+
...(hint ? { hint } : {}),
|
|
72
|
+
candidates: [dir, ...defaults.filter((d) => d !== dir)],
|
|
73
|
+
})
|
|
74
|
+
const explicit = hubEnv(env).HUB_STATE_DIR
|
|
75
|
+
if (explicit) return result(explicit, "env")
|
|
76
|
+
const holdsState = (dir: string) => holdsHubState(dir, exists)
|
|
77
|
+
if (xdgHub && holdsState(xdgHub)) return result(xdgHub, "xdg")
|
|
78
|
+
if (holdsState(legacy)) {
|
|
79
|
+
const hint =
|
|
80
|
+
xdgHub && xdgHub !== legacy
|
|
81
|
+
? `XDG_STATE_HOME is set, but the existing ${legacy} is used as the hub state dir; to switch, stop the hub and OpenCode, then: mv ${shellQuote(legacy)} ${shellQuote(xdgHub)}`
|
|
82
|
+
: undefined
|
|
83
|
+
return result(legacy, "legacy", hint)
|
|
84
|
+
}
|
|
85
|
+
return xdgHub ? result(xdgHub, "fresh") : result(legacy, "default")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let hinted = false
|
|
89
|
+
/** Logs the resolver's hint once per process (at startup). */
|
|
90
|
+
export function hintStateDirOnce(resolved: HubStateDir, log: (line: string) => void = console.warn): void {
|
|
91
|
+
if (!resolved.hint || hinted) return
|
|
92
|
+
hinted = true
|
|
93
|
+
log(`[hub] ${resolved.hint}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The legacy (opencode-hub, opencode-voice) state dirs, most recent first: under $XDG_STATE_HOME, then ~/.local/state. */
|
|
97
|
+
export function stateLegacyPaths(env: Env, home: string): string[] {
|
|
98
|
+
const xdg = xdgStateHome(env)
|
|
99
|
+
const bases = [...(xdg ? [xdg] : []), path.join(home, ".local", "state")]
|
|
100
|
+
return [...new Set(bases.flatMap((base) => legacyPaths(base)))]
|
|
101
|
+
}
|
|
29
102
|
|
|
30
103
|
export type MigrationResult = "migrated" | "none" | "failed"
|
|
31
104
|
|
|
@@ -103,3 +176,36 @@ export function migrateFirst(legacies: readonly string[], target: string, option
|
|
|
103
176
|
|
|
104
177
|
/** The legacy copies of `<base>/<APP_DIR>/<...rest>`, most recent first. */
|
|
105
178
|
export const legacyPaths = (base: string, ...rest: string[]): string[] => LEGACY_APP_DIRS.map((dir) => path.join(base, dir, ...rest))
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* migrateFirst for the hub state dir: a target that holds no hub state (missing, empty, or only `hubctl setup`'s
|
|
182
|
+
* bootstrap/) counts as missing. The newest existing legacy dir is copied through a temporary sibling, then each of
|
|
183
|
+
* its top-level entries is renamed into the target unless the target already has one by that name (bootstrap/ is
|
|
184
|
+
* never overwritten). Never throws; the legacy copies are never touched.
|
|
185
|
+
*/
|
|
186
|
+
export function migrateStateDir(legacies: readonly string[], target: string, options: MigrateOptions = {}): MigrationResult {
|
|
187
|
+
if (!exists(target)) return migrateFirst(legacies, target, options)
|
|
188
|
+
if (holdsHubState(target, exists)) return "none"
|
|
189
|
+
const legacy = legacies.find(exists)
|
|
190
|
+
if (legacy === undefined) return "none"
|
|
191
|
+
const tmp = `${target}.migrating-${process.pid}-${Date.now()}`
|
|
192
|
+
let moved = 0
|
|
193
|
+
try {
|
|
194
|
+
const present = new Set(readdirSync(target))
|
|
195
|
+
copyEntry(legacy, tmp, [...(options.skip ?? []), ...present])
|
|
196
|
+
for (const name of readdirSync(tmp)) {
|
|
197
|
+
if (exists(path.join(target, name))) continue
|
|
198
|
+
renameSync(path.join(tmp, name), path.join(target, name))
|
|
199
|
+
moved++
|
|
200
|
+
}
|
|
201
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
202
|
+
} catch (cause) {
|
|
203
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
204
|
+
;(options.log ?? ((m: string) => console.warn(`[hub] warning: ${m}`)))(`could not migrate ${legacy} to ${target}: ${cause instanceof Error ? cause.message : cause}`)
|
|
205
|
+
return "failed"
|
|
206
|
+
}
|
|
207
|
+
// Everything was there already (an earlier migration of a legacy dir without hub-state markers): nothing to say.
|
|
208
|
+
if (moved === 0) return "none"
|
|
209
|
+
;(options.log ?? ((m: string) => console.log(`[hub] ${m}`)))(`migrated ${legacy} to ${target} (the project is now Hub; the old copy is kept)`)
|
|
210
|
+
return "migrated"
|
|
211
|
+
}
|
package/src/spoke/env-inject.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// they get only the stripped env and PATH, no token.
|
|
15
15
|
import { homedir } from "node:os"
|
|
16
16
|
import path from "node:path"
|
|
17
|
+
import { hubEnv } from "../env"
|
|
17
18
|
import { defaultStateDir } from "./localhub"
|
|
18
19
|
|
|
19
20
|
type Env = Record<string, string | undefined>
|
|
@@ -124,11 +125,17 @@ export function applyHubEnv(env: Env, identity: Identity | undefined, binDir: st
|
|
|
124
125
|
env.PATH = [binDir, ...rest].join(path.delimiter)
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
/** Where the `hub`/`hubctl` launchers live: bin under the hub state dir ($HUB_STATE_DIR
|
|
128
|
+
/** Where the `hub`/`hubctl` launchers live: bin under the hub state dir ($HUB_STATE_DIR, else $XDG_STATE_HOME/hub, else ~/.local/state/hub). */
|
|
128
129
|
export function hubBinDir(env: Env = process.env, home: string = homedir()): string {
|
|
129
130
|
return path.join(defaultStateDir(env, home), "bin")
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
/** Where ~/.local/bin/hub may point: the default (resolved) hub bin dir; none with HUB_STATE_DIR (a custom state). */
|
|
134
|
+
export function defaultHubBinDir(env: Env = process.env, home: string = homedir()): string | undefined {
|
|
135
|
+
if (hubEnv(env).HUB_STATE_DIR) return undefined
|
|
136
|
+
return path.join(defaultStateDir(env, home), "bin")
|
|
137
|
+
}
|
|
138
|
+
|
|
132
139
|
/** The hook handlers: tool execute.before/after and shell create.before. They never throw. */
|
|
133
140
|
export function envInjector(options: { correlator: ShellCorrelator; binDir: string; identity: (sessionID: string) => Identity | undefined }) {
|
|
134
141
|
const { correlator, binDir, identity } = options
|
package/src/spoke/hub-tool.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type SentAll,
|
|
26
26
|
type TeamIntake,
|
|
27
27
|
type TeamIntakeStatusInput,
|
|
28
|
+
wakeSkipNote,
|
|
28
29
|
} from "../../packages/sdk/src/index.ts"
|
|
29
30
|
import { ME_LINE, reachText, whoSections } from "./who-teams"
|
|
30
31
|
|
|
@@ -41,9 +42,11 @@ export const HUB_TOOL_SELF_VERBS = ["name", "role_claim", "role_release"] as con
|
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* (CON-04) The Concierge's verbs (the `conciergeVerbs` section; CON-06 adds concierge_arm and concierge_propose). They
|
|
44
|
-
* use the act token
|
|
45
|
-
* other session. Listed in the Concierge's instructions only, never in the tool description. (CON-11)
|
|
46
|
-
* looks the user's sessions up by @name or title words
|
|
45
|
+
* use the act token. The /api/agent/concierge/* routes admit only the user's CURRENT Concierge session and answer 404
|
|
46
|
+
* to every other session. Listed in the Concierge's instructions only, never in the tool description. (CON-11)
|
|
47
|
+
* concierge_find looks the user's sessions up by @name or title words through GET /api/agent/sessions?q=, an ordinary
|
|
48
|
+
* read that any session (and the user) may make: it is a Concierge verb only for its framed, untrusted output. (FX-07)
|
|
49
|
+
* Text without a letter or digit is answered here without a request (the hub would match nothing).
|
|
47
50
|
*/
|
|
48
51
|
export const HUB_TOOL_CONCIERGE_VERBS = ["concierge_board", "concierge_decisions", "concierge_decision", "concierge_notes", "concierge_notes_set", "concierge_watch", "concierge_unwatch", "concierge_mute", "concierge_unmute", "concierge_arm", "concierge_propose", "concierge_find", "concierge_form"] as const
|
|
49
52
|
export type HubToolConciergeVerb = (typeof HUB_TOOL_CONCIERGE_VERBS)[number]
|
|
@@ -468,8 +471,8 @@ async function ask(client: HubClient, input: Input, send: { urgent?: boolean; wa
|
|
|
468
471
|
while (asked.message.state === "open" && left > 0 && !bound.signal.aborted) {
|
|
469
472
|
const step = Math.min(left, WAIT_POLL_MAX_S)
|
|
470
473
|
left -= step
|
|
471
|
-
const
|
|
472
|
-
asked = { ...(
|
|
474
|
+
const sentAs: Sent = asked
|
|
475
|
+
asked = { ...deliveryMetadata(sentAs), ...(await client.message(asked.message.id, { wait: step, signal: bound.signal })) }
|
|
473
476
|
}
|
|
474
477
|
return askResult(asked)
|
|
475
478
|
} catch (cause) {
|
|
@@ -486,22 +489,31 @@ async function ask(client: HubClient, input: Input, send: { urgent?: boolean; wa
|
|
|
486
489
|
|
|
487
490
|
function askResult(asked: Asked): HubToolResult {
|
|
488
491
|
const { message, answer } = asked
|
|
489
|
-
const metadata = { verb: "ask", id: message.id, state: message.state, ...(asked
|
|
492
|
+
const metadata = { verb: "ask", id: message.id, state: message.state, ...deliveryMetadata(asked) }
|
|
490
493
|
if (answer) return { content: `${message.state === "declined" ? "Declined" : "Answered"}: ${message.id}. ${NOT_FROM_USER}\n${envelope(answer)}`, metadata }
|
|
491
494
|
if (message.state === "expired") return { content: `The ask ${message.id} expired without an answer.`, metadata }
|
|
492
|
-
return { content: `Asked ${message.id} (${asked
|
|
495
|
+
return { content: `Asked ${message.id} (${deliveryNote(asked)}); still open. The answer arrives as a hub message, or see hub inbox.`, metadata }
|
|
493
496
|
}
|
|
494
497
|
|
|
495
498
|
function sent(verb: string, result: Sent): HubToolResult {
|
|
496
499
|
const { message } = result
|
|
497
500
|
return {
|
|
498
|
-
content: `${verb === "decline" ? "Declined" : verb === "reply" ? "Replied" : "Sent"} ${message.id} to ${partyText(message)} (${result
|
|
499
|
-
metadata: { verb, id: message.id, ...(result
|
|
501
|
+
content: `${verb === "decline" ? "Declined" : verb === "reply" ? "Replied" : "Sent"} ${message.id} to ${partyText(message)} (${deliveryNote(result)}).`,
|
|
502
|
+
metadata: { verb, id: message.id, ...deliveryMetadata(result) },
|
|
500
503
|
}
|
|
501
504
|
}
|
|
502
505
|
|
|
506
|
+
/** `queued`, `turn`… and, when the hub did not wake the recipient, why (`queued — wake skipped: hop limit reached …`). */
|
|
507
|
+
const deliveryNote = (result: Sent): string => `${result.delivery ?? "stored"}${wakeSkipNote(result)}`
|
|
508
|
+
|
|
509
|
+
const deliveryMetadata = ({ delivery, woke, wakeSkipped }: Sent) => ({
|
|
510
|
+
...(delivery ? { delivery } : {}),
|
|
511
|
+
...(woke === undefined ? {} : { woke }),
|
|
512
|
+
...(wakeSkipped ? { wakeSkipped } : {}),
|
|
513
|
+
})
|
|
514
|
+
|
|
503
515
|
function sentAll(verb: string, result: SentAll): HubToolResult {
|
|
504
|
-
const lines = result.sent.map((s) => `${s.message.id} → ${partyText(s.message)} (${s
|
|
516
|
+
const lines = result.sent.map((s) => `${s.message.id} → ${partyText(s.message)} (${deliveryNote(s)})`)
|
|
505
517
|
return { content: `Sent to ${result.sent.length} session(s):\n${lines.join("\n")}`, metadata: { verb, ids: result.sent.map((s) => s.message.id) } }
|
|
506
518
|
}
|
|
507
519
|
|
|
@@ -690,6 +702,8 @@ async function conciergeVerb(client: HubClient, verb: HubToolConciergeVerb, inpu
|
|
|
690
702
|
}
|
|
691
703
|
case "concierge_find": {
|
|
692
704
|
const text = str(input, "text", verb)
|
|
705
|
+
// (FX-07) The hub matches words of letters and digits: text without any names no session (never all of them).
|
|
706
|
+
if (!/[\p{L}\p{N}]/u.test(text)) return { content: `No session matches ${framed(text, 200)}: give the @name or title words (letters or digits).`, metadata: { verb, sessions: 0 } }
|
|
693
707
|
const { sessions } = await client.sessions({ q: text, ...signal })
|
|
694
708
|
const roots = sessions.filter((x) => !x.parentID)
|
|
695
709
|
const lines = roots.slice(0, FIND_MAX).map(foundLine)
|
package/src/spoke/launchers.ts
CHANGED
|
@@ -131,10 +131,12 @@ function onPath(name: string, searchPath: string, skip: string): string | undefi
|
|
|
131
131
|
* terminal will not find it (~/.local/bin not on `path`, or another `hub` comes first). Small synchronous calls,
|
|
132
132
|
* nothing when converged (the plugin runs this at start).
|
|
133
133
|
*/
|
|
134
|
-
export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined }): { linked: string[]; hint?: string } {
|
|
135
|
-
// Only the default hub bin dir (
|
|
136
|
-
// state)
|
|
137
|
-
|
|
134
|
+
export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined; readonly defaultBinDir?: string | undefined }): { linked: string[]; hint?: string } {
|
|
135
|
+
// Only the default hub bin dir (env-inject.ts defaultHubBinDir: the resolved state dir's bin, default
|
|
136
|
+
// ~/.local/state/hub/bin): a custom HUB_STATE_DIR (tests, containers, a throwaway state) must never leave
|
|
137
|
+
// ~/.local/bin pointing into it.
|
|
138
|
+
const allowed = options.defaultBinDir ?? path.join(options.home, ".local", "state", "hub", "bin")
|
|
139
|
+
if (path.resolve(options.binDir) !== path.resolve(allowed)) return { linked: [] }
|
|
138
140
|
const userBin = userBinDir(options.home)
|
|
139
141
|
const linkPath = path.join(userBin, "hub")
|
|
140
142
|
const target = path.join(options.binDir, "hub")
|
package/src/spoke/listing.ts
CHANGED
|
@@ -22,14 +22,19 @@ export interface RawProject {
|
|
|
22
22
|
readonly name?: string
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Per-request options (the OpenCode client's RequestOptions): the listing's cancellation. */
|
|
26
|
+
export interface ListingRequest {
|
|
27
|
+
readonly signal?: AbortSignal
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
/** The parts of the OpenCode client the listing uses. */
|
|
26
31
|
export interface ListingClient {
|
|
27
32
|
readonly session: {
|
|
28
|
-
list(input: { limit?: number; parentID?: null; cursor?: string }): Promise<{ data: readonly RawSession[]; cursor: { next?: string | null } }>
|
|
33
|
+
list(input: { limit?: number; parentID?: null; cursor?: string }, request?: ListingRequest): Promise<{ data: readonly RawSession[]; cursor: { next?: string | null } }>
|
|
29
34
|
/** One session by ID (for always-included sessions outside the window). */
|
|
30
|
-
get?(input: { sessionID: string }): Promise<RawSession>
|
|
35
|
+
get?(input: { sessionID: string }, request?: ListingRequest): Promise<RawSession>
|
|
31
36
|
}
|
|
32
|
-
readonly project: { list(): Promise<readonly RawProject[]> }
|
|
37
|
+
readonly project: { list(request?: ListingRequest): Promise<readonly RawProject[]> }
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export interface ListedSession {
|
|
@@ -57,6 +62,8 @@ export interface ListingOptions {
|
|
|
57
62
|
* projects, or in a directory under one of them, are listed. Absent: every session (the background service).
|
|
58
63
|
*/
|
|
59
64
|
readonly scope?: readonly string[]
|
|
65
|
+
/** Cancels the listing: passed to every request, and no request starts once it aborted (the listing rejects). */
|
|
66
|
+
readonly signal?: AbortSignal
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
/** Most always-included sessions fetched one by one per listing. */
|
|
@@ -114,9 +121,12 @@ export async function listServiceSessions(client: ListingClient, options: Listin
|
|
|
114
121
|
if (options.scope?.length === 0) return []
|
|
115
122
|
const pageSize = options.pageSize ?? 100
|
|
116
123
|
const maxPages = options.maxPages ?? 20
|
|
124
|
+
const { signal } = options
|
|
125
|
+
const request: ListingRequest = signal ? { signal } : {}
|
|
126
|
+
signal?.throwIfAborted()
|
|
117
127
|
const projects = new Map<string, RawProject>()
|
|
118
128
|
try {
|
|
119
|
-
for (const project of await client.project.list()) projects.set(project.id, project)
|
|
129
|
+
for (const project of await client.project.list(request)) projects.set(project.id, project)
|
|
120
130
|
} catch {
|
|
121
131
|
// workspaces fall back to directories
|
|
122
132
|
}
|
|
@@ -129,7 +139,8 @@ export async function listServiceSessions(client: ListingClient, options: Listin
|
|
|
129
139
|
// Sessions come newest first: the first one older than the window ends the listing.
|
|
130
140
|
let ended = false
|
|
131
141
|
for (let page = 0; page < maxPages && out.length < max && !ended; page++) {
|
|
132
|
-
|
|
142
|
+
signal?.throwIfAborted()
|
|
143
|
+
const result = await client.session.list({ limit: pageSize, parentID: null, ...(cursor ? { cursor } : {}) }, request)
|
|
133
144
|
for (const s of result.data) {
|
|
134
145
|
if (s.parentID || s.time.archived || out.length >= max) continue
|
|
135
146
|
if (s.time.updated < cutoff) {
|
|
@@ -144,7 +155,8 @@ export async function listServiceSessions(client: ListingClient, options: Listin
|
|
|
144
155
|
const have = new Set(out.map((s) => s.id))
|
|
145
156
|
const missing = [...new Set(options.include ?? [])].filter((id) => !have.has(id)).slice(0, INCLUDE_FETCH_MAX)
|
|
146
157
|
if (missing.length && client.session.get) {
|
|
147
|
-
|
|
158
|
+
signal?.throwIfAborted()
|
|
159
|
+
const fetched = await Promise.all(missing.map((sessionID) => client.session.get!({ sessionID }, request).catch(() => undefined)))
|
|
148
160
|
for (const s of fetched) if (s && s.id && !s.parentID && !s.time?.archived && s.location?.directory && wanted(s)) out.push(listed(s))
|
|
149
161
|
}
|
|
150
162
|
return out
|