@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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// (+ artifacts:read) for an app token. Content never travels in requests or job listings, only in the context.
|
|
8
8
|
import type { ArtifactJob, ArtifactJobContext, ArtifactJobKind, ArtifactJobRequest } from "../../sdk/src/artifact-jobs.ts"
|
|
9
9
|
import type { Artifacts } from "../../sdk/src/artifacts.ts"
|
|
10
|
+
import { ToolArgError } from "./tool-args"
|
|
10
11
|
|
|
11
12
|
/** UTF-8 bytes of the inline proposal text one tool call may carry. */
|
|
12
13
|
export const MCP_JOB_TEXT_BYTES = 256 * 1024
|
|
@@ -33,27 +34,27 @@ const KINDS: readonly ArtifactJobKind[] = ["ask", "propose-edit", "run-action"]
|
|
|
33
34
|
|
|
34
35
|
const need = (args: Args, key: string): string => {
|
|
35
36
|
const value = args[key]
|
|
36
|
-
if (typeof value !== "string" || !value.trim()) throw new
|
|
37
|
+
if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
|
|
37
38
|
return value
|
|
38
39
|
}
|
|
39
40
|
const optional = (args: Args, key: string): string | undefined => {
|
|
40
41
|
const value = args[key]
|
|
41
42
|
if (value === undefined || value === null) return undefined
|
|
42
|
-
if (typeof value !== "string") throw new
|
|
43
|
+
if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
|
|
43
44
|
return value.trim() ? value : undefined
|
|
44
45
|
}
|
|
45
46
|
const integer = (args: Args, key: string): number => {
|
|
46
47
|
const value = args[key]
|
|
47
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new
|
|
48
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new ToolArgError(`"${key}" is required (a whole number)`)
|
|
48
49
|
return value
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function handlerOf(args: Args): ArtifactJobRequest["handler"] {
|
|
52
53
|
const session = optional(args, "session")
|
|
53
54
|
const machine = optional(args, "machine")
|
|
54
|
-
if (session && machine) throw new
|
|
55
|
+
if (session && machine) throw new ToolArgError('give either "session" (an existing session) or "machine" + "repo" (a new handler session), not both')
|
|
55
56
|
if (session) return { session }
|
|
56
|
-
if (!machine) throw new
|
|
57
|
+
if (!machine) throw new ToolArgError('"machine" and "repo" are required to launch a handler session (or name an existing one with "session")')
|
|
57
58
|
const repo = need(args, "repo")
|
|
58
59
|
const harness = optional(args, "harness")
|
|
59
60
|
const model = optional(args, "model")
|
|
@@ -114,7 +115,7 @@ export const artifactJobTools: readonly ArtifactJobTool[] = [
|
|
|
114
115
|
readOnly: false,
|
|
115
116
|
run: async (hub, args, signal) => {
|
|
116
117
|
const kind = optional(args, "kind") as ArtifactJobKind | undefined
|
|
117
|
-
if (kind && !KINDS.includes(kind)) throw new
|
|
118
|
+
if (kind && !KINDS.includes(kind)) throw new ToolArgError(`"kind" must be one of ${KINDS.join(", ")}`)
|
|
118
119
|
const action = optional(args, "action")
|
|
119
120
|
const apply = optional(args, "apply") as "manual" | "auto" | undefined
|
|
120
121
|
const key = optional(args, "key")
|
|
@@ -181,10 +182,10 @@ export const artifactJobTools: readonly ArtifactJobTool[] = [
|
|
|
181
182
|
const error = optional(args, "error")
|
|
182
183
|
const answer = optional(args, "answer")
|
|
183
184
|
const text = args.text
|
|
184
|
-
if (text !== undefined && typeof text !== "string") throw new
|
|
185
|
-
if (typeof text === "string" && Buffer.byteLength(text, "utf8") > MCP_JOB_TEXT_BYTES) throw new
|
|
185
|
+
if (text !== undefined && typeof text !== "string") throw new ToolArgError('"text" must be a string')
|
|
186
|
+
if (typeof text === "string" && Buffer.byteLength(text, "utf8") > MCP_JOB_TEXT_BYTES) throw new ToolArgError(`"text" is larger than ${MCP_JOB_TEXT_BYTES} bytes`)
|
|
186
187
|
const keep = args.keep
|
|
187
|
-
if (keep !== undefined && (!Array.isArray(keep) || !keep.every((k) => typeof k === "string"))) throw new
|
|
188
|
+
if (keep !== undefined && (!Array.isArray(keep) || !keep.every((k) => typeof k === "string"))) throw new ToolArgError('"keep" must be a list of paths')
|
|
188
189
|
const path = optional(args, "path")
|
|
189
190
|
const entry = optional(args, "entry")
|
|
190
191
|
const result = error
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// tool ever reads local files). Needs scope artifacts:read / artifacts:write for an app token; sessions create and
|
|
6
6
|
// read workspace-shared artifacts only (revising stays with the user in v1).
|
|
7
7
|
import type { Artifact, ArtifactKind, ArtifactRevision, Artifacts, BlobRef } from "../../sdk/src/artifacts.ts"
|
|
8
|
+
import { ToolArgError } from "./tool-args"
|
|
8
9
|
|
|
9
10
|
/** UTF-8 bytes of inline text one tool call may carry (bigger content: stage it, then pass `blobs`). */
|
|
10
11
|
export const MCP_INLINE_TEXT_BYTES = 256 * 1024
|
|
@@ -45,24 +46,24 @@ const FILE_ARGS = {
|
|
|
45
46
|
|
|
46
47
|
const need = (args: Args, key: string): string => {
|
|
47
48
|
const value = args[key]
|
|
48
|
-
if (typeof value !== "string" || !value.trim()) throw new
|
|
49
|
+
if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
|
|
49
50
|
return value
|
|
50
51
|
}
|
|
51
52
|
const optional = (args: Args, key: string): string | undefined => {
|
|
52
53
|
const value = args[key]
|
|
53
54
|
if (value === undefined || value === null) return undefined
|
|
54
|
-
if (typeof value !== "string") throw new
|
|
55
|
+
if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
|
|
55
56
|
return value.trim() ? value : undefined
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/** The files of a publish/revise: inline text and/or blob references (one of them required). */
|
|
59
60
|
function files(args: Args, kind: ArtifactKind | undefined): { blobs?: BlobRef[]; upload?: { mediaType: string; data: string; path?: string }[]; entry?: string } {
|
|
60
61
|
const text = args.text
|
|
61
|
-
if (text !== undefined && typeof text !== "string") throw new
|
|
62
|
+
if (text !== undefined && typeof text !== "string") throw new ToolArgError('"text" must be a string')
|
|
62
63
|
const blobs = args.blobs
|
|
63
|
-
if (blobs !== undefined && (!Array.isArray(blobs) || !blobs.every((b) => typeof b === "object" && b !== null))) throw new
|
|
64
|
-
if (text === undefined && (blobs === undefined || blobs.length === 0)) throw new
|
|
65
|
-
if (text !== undefined && Buffer.byteLength(text, "utf8") > MCP_INLINE_TEXT_BYTES) throw new
|
|
64
|
+
if (blobs !== undefined && (!Array.isArray(blobs) || !blobs.every((b) => typeof b === "object" && b !== null))) throw new ToolArgError('"blobs" must be a list of blob references')
|
|
65
|
+
if (text === undefined && (blobs === undefined || blobs.length === 0)) throw new ToolArgError('give text or blobs (stage bigger content first and pass its reference)')
|
|
66
|
+
if (text !== undefined && Buffer.byteLength(text, "utf8") > MCP_INLINE_TEXT_BYTES) throw new ToolArgError(`text is larger than ${MCP_INLINE_TEXT_BYTES} bytes: stage it with uploadBlob and pass blobs`)
|
|
66
67
|
const path = optional(args, "path")
|
|
67
68
|
const entry = optional(args, "entry")
|
|
68
69
|
const mediaType = optional(args, "mediaType") ?? (kind && DEFAULT_MEDIA[kind]) ?? "text/plain"
|
|
@@ -126,7 +127,7 @@ export const artifactTools: readonly ArtifactTool[] = [
|
|
|
126
127
|
run: async (hub, args, signal) => {
|
|
127
128
|
const title = need(args, "title")
|
|
128
129
|
const kind = need(args, "kind") as ArtifactKind
|
|
129
|
-
if (!KINDS.includes(kind)) throw new
|
|
130
|
+
if (!KINDS.includes(kind)) throw new ToolArgError(`"kind" must be one of ${KINDS.join(", ")}`)
|
|
130
131
|
const body = files(args, kind)
|
|
131
132
|
const description = optional(args, "description")
|
|
132
133
|
const visibility = optional(args, "visibility") as "private" | "workspace" | undefined
|
|
@@ -31,8 +31,8 @@ import { EMBEDDED_TEXT } from "../../release/assets.ts"
|
|
|
31
31
|
/** The settings each service may carry into its unit (as `hub <service> install` carries them today). */
|
|
32
32
|
export const SERVICE_ENVIRONMENT: Readonly<Record<ServiceID, readonly string[]>> = Object.freeze({
|
|
33
33
|
node: ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_CLAUDE_BIN", "HUB_CODEX_BIN"],
|
|
34
|
-
"claude-code": ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR", "HUB_CLAUDE_SOCKET", "HUB_CLAUDE_BIN"],
|
|
35
|
-
"codex-spoke": ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR", "CODEX_HOME", "HUB_CODEX_BIN", "HUB_CODEX_DAEMON_SOCKET"],
|
|
34
|
+
"claude-code": ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR", "XDG_STATE_HOME", "HUB_CLAUDE_SOCKET", "HUB_CLAUDE_BIN"],
|
|
35
|
+
"codex-spoke": ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR", "XDG_STATE_HOME", "CODEX_HOME", "HUB_CODEX_BIN", "HUB_CODEX_DAEMON_SOCKET"],
|
|
36
36
|
})
|
|
37
37
|
|
|
38
38
|
/** The named root each manager's units live in. */
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// container kind. Everything is read through the probe; nothing is executed.
|
|
3
3
|
import { ROOT_IDS, type ContainerKind, type Libc, type RootID, type RootMapping } from "../contracts/index.ts"
|
|
4
4
|
import type { DiscoveryProbe } from "./probe.ts"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { resolveHubStateDir } from "../../../../../src/migrate.ts"
|
|
5
7
|
import { decode, join, normalizeAbs, oneLine } from "./util.ts"
|
|
6
8
|
|
|
7
9
|
// ---------- roots ----------
|
|
@@ -14,7 +16,7 @@ export function discoverRoots(probe: DiscoveryProbe): RootMapping[] | undefined
|
|
|
14
16
|
const paths: Partial<Record<RootID, string>> = {
|
|
15
17
|
home,
|
|
16
18
|
config,
|
|
17
|
-
state: xdg("XDG_STATE_HOME", ".local/state"),
|
|
19
|
+
state: hubStateRoot(probe, home) ?? xdg("XDG_STATE_HOME", ".local/state"),
|
|
18
20
|
data: xdg("XDG_DATA_HOME", ".local/share"),
|
|
19
21
|
"user-bin": join(home, ".local/bin"),
|
|
20
22
|
"system-bin": "/usr/local/bin",
|
|
@@ -24,6 +26,18 @@ export function discoverRoots(probe: DiscoveryProbe): RootMapping[] | undefined
|
|
|
24
26
|
return ROOT_IDS.flatMap((id) => (paths[id] ? [{ id, path: paths[id]! }] : []))
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The state root: the parent of the hub state dir (resolveHubStateDir, src/migrate.ts, asked through the probe), so
|
|
31
|
+
* setup's <state>/hub/bootstrap lands in the dir the hub uses. Undefined (the XDG default) when that dir is not named
|
|
32
|
+
* "hub" (a custom HUB_STATE_DIR): the plan's paths are <state>/hub/….
|
|
33
|
+
*/
|
|
34
|
+
function hubStateRoot(probe: DiscoveryProbe, home: string): string | undefined {
|
|
35
|
+
const env = { ...probe.env, ...(probe.env.XDG_STATE_HOME !== undefined ? { XDG_STATE_HOME: normalizeAbs(probe.env.XDG_STATE_HOME) } : {}) }
|
|
36
|
+
const { dir } = resolveHubStateDir(env, home, (p) => probe.lstat(p) !== "missing")
|
|
37
|
+
const abs = normalizeAbs(dir)
|
|
38
|
+
return abs && path.posix.basename(abs) === "hub" ? path.posix.dirname(abs) : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
export interface Mount {
|
|
28
42
|
readonly root: string
|
|
29
43
|
readonly point: string
|
|
@@ -19,7 +19,7 @@ export interface DiscoveryProbe {
|
|
|
19
19
|
/** The user's home directory (os.homedir()). */
|
|
20
20
|
readonly home: string
|
|
21
21
|
readonly hostname: string
|
|
22
|
-
/** Read, never recorded: discovery looks at PATH, HOME, XDG_
|
|
22
|
+
/** Read, never recorded: discovery looks at PATH, HOME, XDG_*, HUB_STATE_DIR and `container` only. */
|
|
23
23
|
readonly env: Readonly<Record<string, string | undefined>>
|
|
24
24
|
/** The runtime running this script, when hubctl runs as a script (absent for a standalone binary). */
|
|
25
25
|
readonly runtime?: RuntimeFact
|
|
@@ -18,7 +18,7 @@ import { hostname } from "node:os"
|
|
|
18
18
|
import type { EnrollStart } from "../../../../protocol/src/index.ts"
|
|
19
19
|
import { EnrollmentRefused, pollEnrollment, startEnrollment } from "../../../../spoke-sdk/src/enroll.ts"
|
|
20
20
|
import type { CredentialStore, MachineCredential, MachineEnrollment } from "../contracts/index.ts"
|
|
21
|
-
import { approvalURL, checkServerURL, insecureHTTPRefusal, insecureHTTPWarning, isLoopbackServer, isPlainHTTP, isUserCode } from "./policy.ts"
|
|
21
|
+
import { approvalURL, checkServerURL, insecureHTTPRefusal, insecureHTTPWarning, isLoopbackServer, isPlainHTTP, isUserCode, shownURL } from "./policy.ts"
|
|
22
22
|
import { failure, line, success, type MachineResult } from "./result.ts"
|
|
23
23
|
import { CredentialStoreError, type SharingMode, type StoreContents } from "./store.ts"
|
|
24
24
|
import { probeCredential, statusOf, storeFailure } from "./status.ts"
|
|
@@ -133,8 +133,8 @@ export async function machineEnroll(options: MachineEnrollOptions): Promise<Mach
|
|
|
133
133
|
const server = checkServerURL(options.server, allow)
|
|
134
134
|
if (!server) {
|
|
135
135
|
const why = insecureHTTPRefusal(options.server, allow)
|
|
136
|
-
if (why) return failure(COMMAND, "usage", `${why[0]!.toUpperCase()}${why.slice(1)}: ${line(String(options.server), 200)}`)
|
|
137
|
-
return failure(COMMAND, "usage", `The hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${line(String(options.server), 200)}`)
|
|
136
|
+
if (why) return failure(COMMAND, "usage", `${why[0]!.toUpperCase()}${why.slice(1)}: ${line(shownURL(String(options.server)), 200)}`)
|
|
137
|
+
return failure(COMMAND, "usage", `The hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${line(shownURL(String(options.server)), 200)}`)
|
|
138
138
|
}
|
|
139
139
|
const insecure = isPlainHTTP(server) && !isLoopbackServer(server) ? insecureHTTPWarning(server) : undefined
|
|
140
140
|
const machineName = options.machineName ?? defaultMachineName(hostname())
|
|
@@ -165,3 +165,16 @@ export function storedInsecureWarning(value: string): string {
|
|
|
165
165
|
} catch {}
|
|
166
166
|
return `the stored hub URL ${shown} is plain http: your app token and everything this command sends or receives travel unencrypted on the network; log in again with an https URL (hub login --url https://<hub>)`
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A URL as typed, for a refusal message, at most 200 characters: anything before an `@` in its authority-looking
|
|
171
|
+
* part (user info: a password or token) is replaced by ***. The authority starts after an http(s) scheme or any
|
|
172
|
+
* scheme followed by slashes or backslashes (`http:u:p@h`, `http:/u:p@h`, `http:\\u:p@h`), else at the start
|
|
173
|
+
* (`user:pass@h`), and ends at the first /, \\, ? or #. Leading whitespace is dropped.
|
|
174
|
+
*/
|
|
175
|
+
export function shownURL(value: string): string {
|
|
176
|
+
return value
|
|
177
|
+
.trimStart()
|
|
178
|
+
.replace(/^((?:https?:|[a-z][a-z0-9+.-]*:(?=[\/\\]))?[\/\\]*)[^\/\\?#]*@/i, "$1***@")
|
|
179
|
+
.slice(0, 200)
|
|
180
|
+
}
|
package/packages/cli/src/cli.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { loginFile, readLogin, removeLogin, resolveCredentials, writeLogin, type
|
|
|
8
8
|
import { atName, logText, whoamiText, type Actor, roleText, sessionsText, showText, statusText, whoText } from "./format"
|
|
9
9
|
import { CliError, EXIT, withHub } from "./http"
|
|
10
10
|
import { deviceLogin, promptText, scopeNotes } from "./login"
|
|
11
|
-
import { checkServerURL, insecureHTTPRefusal, insecureHTTPWarning, isInsecureStoredURL, isLoopbackServer, isPlainHTTP, storedInsecureWarning } from "./bootstrap/enrollment/policy.ts"
|
|
11
|
+
import { checkServerURL, insecureHTTPRefusal, insecureHTTPWarning, isInsecureStoredURL, isLoopbackServer, isPlainHTTP, shownURL, storedInsecureWarning } from "./bootstrap/enrollment/policy.ts"
|
|
12
12
|
import { defaultMachineName } from "./bootstrap/enrollment/enroll.ts"
|
|
13
13
|
import { readSpokeCredentials } from "../../node/src/config.ts"
|
|
14
14
|
import { hostname } from "node:os"
|
|
@@ -211,7 +211,7 @@ Options: --json (raw responses), --help, --version
|
|
|
211
211
|
Addresses: ses_… (or an unambiguous prefix), @name, @@role, @@role#team (quote it: '@@dev#team'), @name.project, @me (the user)
|
|
212
212
|
Credentials: HUB_URL + HUB_SESSION_TOKEN (set in a session's shells), else the \`hub login\` file.
|
|
213
213
|
Exit codes: 0 ok, 1 other, 2 usage, 3 not found/ambiguous/taken, 4 needs user, 5 forbidden/paused,
|
|
214
|
-
6 rate limited, 7 unsupported/offline, 8 gone, 9 unauthorized/expired`
|
|
214
|
+
6 rate limited, 7 unsupported/offline/busy, 8 gone, 9 unauthorized/expired`
|
|
215
215
|
|
|
216
216
|
interface Context {
|
|
217
217
|
readonly io: Io
|
|
@@ -707,7 +707,7 @@ const COMMANDS: Record<string, Command> = {
|
|
|
707
707
|
const server = checkServerURL(url, allow)
|
|
708
708
|
if (!server) {
|
|
709
709
|
const why = insecureHTTPRefusal(url, allow)
|
|
710
|
-
throw new UsageError(why ? `${why}: ${url
|
|
710
|
+
throw new UsageError(why ? `${why}: ${shownURL(url)}` : `the hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${shownURL(url)}`)
|
|
711
711
|
}
|
|
712
712
|
if (isPlainHTTP(server) && !isLoopbackServer(server)) io.stderr(`hub: warning: ${insecureHTTPWarning(server)}\n`)
|
|
713
713
|
token = (given !== undefined && given !== "-" ? given : io.stdin ? await io.stdin() : "").trim()
|
package/packages/cli/src/http.ts
CHANGED
|
@@ -121,10 +121,15 @@ export function launchedText(launch: LaunchView, created: boolean, key: string |
|
|
|
121
121
|
|
|
122
122
|
export const stoppedLaunchText = (launch: LaunchView): string => `stopped ${summary(launch)}`
|
|
123
123
|
|
|
124
|
-
/**
|
|
124
|
+
/** The app token that launched it: `app "label"` or `app <token id>`. */
|
|
125
|
+
const launcherText = (l: LaunchView) => (l.byLabel ? `app ${JSON.stringify(l.byLabel)}` : `app ${l.by?.replace(/^app:/, "") ?? "?"}`)
|
|
126
|
+
|
|
127
|
+
/** `lch_1 running @api ses_1 /repo (branch) app=brain`; a queued one says why it waits, a revoked launcher is flagged. */
|
|
125
128
|
export function launchLine(l: LaunchView): string {
|
|
126
129
|
const where = l.directory ?? `${l.spec.workspace.repo}${l.spec.workspace.branch ? ` (${l.spec.workspace.branch})` : ""}`
|
|
127
|
-
|
|
130
|
+
const waits = l.state === "queued" && l.waiting ? `waits: ${l.waiting.reason}` : undefined
|
|
131
|
+
const revoked = l.launcherRevoked && l.state !== "ended" ? `launcher revoked (${launcherText(l)}): stop it with hub launches stop ${l.id}` : undefined
|
|
132
|
+
return [l.id, stateText(l), sessionText(l) || undefined, where, labelsText(l.spec.labels) || undefined, waits, revoked].filter(Boolean).join(" ")
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
export const launchesText = (launches: readonly LaunchView[]): string => (launches.length ? launches.map(launchLine).join("\n") : "no launches")
|
|
@@ -149,7 +154,8 @@ export function launchText(l: LaunchView): string {
|
|
|
149
154
|
const run = [l.spec.harness, l.spec.agent, l.spec.model, l.spec.runtime ? `${l.spec.runtime.kind}${l.spec.runtime.profile ? `/${l.spec.runtime.profile}` : ""}` : undefined].filter(Boolean)
|
|
150
155
|
if (run.length) lines.push(`runs: ${run.join(" ")}`)
|
|
151
156
|
if (l.spec.end) lines.push(`ends: ${[l.spec.end.when, l.spec.end.timeout ? `timeout ${l.spec.end.timeout}` : undefined, l.spec.end.cleanup ? `cleanup ${l.spec.end.cleanup}` : undefined].filter(Boolean).join(", ")}`)
|
|
152
|
-
if (l.
|
|
157
|
+
if (l.state === "queued" && l.waiting) lines.push(`waits (${l.waiting.wait}) since ${new Date(l.waiting.since).toISOString()}: ${l.waiting.reason}`)
|
|
158
|
+
if (l.by) lines.push(`by: ${l.by}${l.byLabel ? ` (${l.byLabel})` : ""}${l.launcherRevoked ? ", launcher revoked" : ""}`)
|
|
153
159
|
if (labelsText(l.spec.labels)) lines.push(`labels: ${labelsText(l.spec.labels)}`)
|
|
154
160
|
if (l.artifacts && artifactsText(l.artifacts)) lines.push(`artifacts: ${artifactsText(l.artifacts)}`)
|
|
155
161
|
if (l.history?.length) lines.push("steps:", ...l.history.map(stepText))
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// - The wait is bounded by the hub's expiry and by maxWaitMs; `signal` cancels it.
|
|
11
11
|
// - The token is returned to the caller only, never printed. A poll that fails in transit is retried until the
|
|
12
12
|
// deadline (a reply lost after the hub handed the token out cannot be fetched again: the user revokes that token).
|
|
13
|
-
import { approvalURL, checkServerURL, insecureHTTPRefusal, insecureHTTPWarning, isLoopbackServer, isPlainHTTP, isUserCode } from "./bootstrap/enrollment/policy.ts"
|
|
13
|
+
import { approvalURL, checkServerURL, insecureHTTPRefusal, insecureHTTPWarning, isLoopbackServer, isPlainHTTP, isUserCode, shownURL } from "./bootstrap/enrollment/policy.ts"
|
|
14
14
|
import { UsageError } from "./args"
|
|
15
15
|
import { CliError, EXIT } from "./http"
|
|
16
16
|
|
|
@@ -101,7 +101,7 @@ export async function deviceLogin(options: DeviceLoginOptions): Promise<DeviceLo
|
|
|
101
101
|
const server = checkServerURL(options.server, allow)
|
|
102
102
|
if (!server) {
|
|
103
103
|
const why = insecureHTTPRefusal(options.server, allow)
|
|
104
|
-
throw new UsageError(why ? `${why}: ${String(options.server)
|
|
104
|
+
throw new UsageError(why ? `${why}: ${shownURL(String(options.server))}` : `the hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${shownURL(String(options.server))}`)
|
|
105
105
|
}
|
|
106
106
|
const insecure = isPlainHTTP(server) && !isLoopbackServer(server) ? insecureHTTPWarning(server) : undefined
|
|
107
107
|
const fetcher = options.fetch ?? fetch
|
package/packages/cli/src/mcp.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// artifact_job_cancel; scope artifacts:ai: packages/cli/src/artifact-jobs-mcp.ts), and team intake
|
|
9
9
|
// (team_submit, team_intake, team_intake_update; scope teams:submit: packages/cli/src/team-intake-mcp.ts), and teams
|
|
10
10
|
// (teams, team_show, team_status, team_create, team_publish, team_staff, …; scopes teams:read|manage: teams-mcp.ts),
|
|
11
|
-
//
|
|
11
|
+
// the profile lifecycle (profile_show, profile_create, profile_update, profile_version_create, profile_skill_set, …,
|
|
12
|
+
// profile_assign; scopes profiles:read|manage|assign: profiles-mcp.ts), and the Concierge watchlist (watchlist_get,
|
|
13
|
+
// watchlist_update, watchlist_watch, watchlist_mute: watchlist.ts).
|
|
12
14
|
import { renderEnvelope, sanitizeText, WAIT_POLL_MAX_S, type AgentMessage, type Asked, type DecisionState, type FormAnswer, type HubClient, type LaunchSpec, type LaunchState, type PromptDelivery, type Inbox, type Sent, type SentAll } from "../../sdk/src/index.ts"
|
|
13
15
|
import { answeredText, approvedText, DELIVERIES, deniedText, needsUserLines, promptedText, stoppedText } from "./acting"
|
|
14
16
|
import { UsageError } from "./args"
|
|
@@ -24,6 +26,8 @@ import { artifactTools } from "./artifacts-mcp"
|
|
|
24
26
|
import { artifactJobTools } from "./artifact-jobs-mcp"
|
|
25
27
|
import { teamIntakeTools } from "./team-intake-mcp"
|
|
26
28
|
import { teamTools } from "./teams-mcp"
|
|
29
|
+
import { profileLifecycleTools } from "./profiles-mcp"
|
|
30
|
+
import { ToolArgError } from "./tool-args"
|
|
27
31
|
import { watchlistTools } from "./watchlist"
|
|
28
32
|
|
|
29
33
|
/** The protocol revisions the server speaks, newest first: a client's supported one is echoed, else the newest. */
|
|
@@ -45,7 +49,7 @@ type Id = string | number | null
|
|
|
45
49
|
type Args = Record<string, unknown>
|
|
46
50
|
|
|
47
51
|
/** A bad tool argument (an isError result, not a protocol error). */
|
|
48
|
-
|
|
52
|
+
const ArgError = ToolArgError
|
|
49
53
|
|
|
50
54
|
const need = (args: Args, key: string): string => {
|
|
51
55
|
const value = args[key]
|
|
@@ -205,7 +209,7 @@ function inboxText(box: Inbox): string {
|
|
|
205
209
|
/** One message (message_show): its state, the message and, once answered or declined, the answer, in envelopes. */
|
|
206
210
|
function messageText({ message, answer }: Asked): string {
|
|
207
211
|
// (CON-10) A message dropped for a recipient that ended for good says so.
|
|
208
|
-
const lines = [NOT_FROM_USER, `${message.kind} ${message.id}: ${message.state}${message.delivery === "dropped" ? ` · ${deliveryText(
|
|
212
|
+
const lines = [NOT_FROM_USER, `${message.kind} ${message.id}: ${message.state}${message.delivery === "dropped" || message.delivery === "expired" ? ` · ${deliveryText(message.delivery)}` : ""}`, envelope(message)]
|
|
209
213
|
if (answer) lines.push(`${message.state} by ${answer.id}:`, envelope(answer))
|
|
210
214
|
return lines.join("\n")
|
|
211
215
|
}
|
|
@@ -732,6 +736,7 @@ const TOOLS: readonly Tool[] = [
|
|
|
732
736
|
eventsTool,
|
|
733
737
|
...webhookTools,
|
|
734
738
|
...profileTools,
|
|
739
|
+
...profileLifecycleTools,
|
|
735
740
|
...workspaceTools,
|
|
736
741
|
...artifactTools,
|
|
737
742
|
...artifactJobTools,
|
|
@@ -1139,7 +1144,7 @@ export function createMcpServer(options: McpServerOptions): McpServer {
|
|
|
1139
1144
|
return controller.signal.aborted ? undefined : ok(id, toolResult(clean(text, token), false))
|
|
1140
1145
|
} catch (cause) {
|
|
1141
1146
|
if (controller.signal.aborted) return undefined // cancelled: no answer (MCP cancellation)
|
|
1142
|
-
const text = cause instanceof
|
|
1147
|
+
const text = cause instanceof ToolArgError ? `invalid: ${cause.message}` : cause instanceof CliError ? errorText(cause) : `${tool.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
1143
1148
|
return ok(id, toolResult(clean(text, token), true))
|
|
1144
1149
|
} finally {
|
|
1145
1150
|
if (inFlight.get(key(id)) === controller) inFlight.delete(key(id))
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The messaging verbs' helpers (design §3): text arguments (or "-" for stdin) and the short text of what the hub
|
|
2
2
|
// answers to msg, ask, reply, decline, inbox and mute. The shapes are packages/sdk's types (message-routes.ts).
|
|
3
3
|
import type { AgentMessage, Asked, Inbox, MessageDelivery, Party, Replied, Sent } from "../../sdk/src/index.ts"
|
|
4
|
+
import { wakeSkipNote } from "../../sdk/src/wake.ts"
|
|
4
5
|
import { UsageError } from "./args"
|
|
5
6
|
import { oneLine } from "./format"
|
|
6
7
|
|
|
@@ -30,6 +31,7 @@ const DELIVERY: Record<MessageDelivery, string> = {
|
|
|
30
31
|
// Offline recipients, and a --no-wake message to a busy OpenCode session (RF-05); the hub's answer does not say which.
|
|
31
32
|
pending: "pending (offline or busy; delivered when it reconnects or its turn ends, for 24 h)",
|
|
32
33
|
dropped: "not delivered (its recipient session ended)",
|
|
34
|
+
expired: "not delivered (its recipient stayed offline or busy for 24 h)",
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export const deliveryText = (delivery: MessageDelivery | undefined): string => (delivery ? (DELIVERY[delivery] ?? delivery) : "stored (in its inbox)")
|
|
@@ -40,8 +42,8 @@ function toText(message: AgentMessage): string {
|
|
|
40
42
|
return message.to === recipient || message.to === (message.recipient as { sessionID?: string }).sessionID ? recipient : `${message.to} → ${recipient}`
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
/** `msg_… to @web-work: delivered (started a turn)
|
|
44
|
-
export const sentText = (
|
|
45
|
+
/** `msg_… to @web-work: delivered (started a turn)`; `…: queued (read at its next turn) — wake skipped: …` */
|
|
46
|
+
export const sentText = (sent: Sent): string => `${sent.message.kind} ${sent.message.id} to ${toText(sent.message)}: ${deliveryText(sent.delivery)}${wakeSkipNote(sent)}`
|
|
45
47
|
|
|
46
48
|
/** How long an ask stays open when --expires is not given (the hub's default). */
|
|
47
49
|
export const ASK_EXPIRES_DEFAULT_S = 24 * 60 * 60
|