@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.
Files changed (112) hide show
  1. package/CHANGELOG.md +213 -1
  2. package/README.md +84 -15
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/paths.ts +3 -3
  6. package/packages/cli/dist/hub-cli.js +649 -242
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  9. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  10. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  11. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  12. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  13. package/packages/cli/src/cli.ts +3 -3
  14. package/packages/cli/src/http.ts +1 -0
  15. package/packages/cli/src/launches.ts +9 -3
  16. package/packages/cli/src/login.ts +2 -2
  17. package/packages/cli/src/mcp.ts +7 -3
  18. package/packages/cli/src/messaging.ts +4 -2
  19. package/packages/cli/src/profiles-mcp.ts +301 -0
  20. package/packages/cli/src/setup/commands.ts +20 -2
  21. package/packages/cli/src/spoke-services.ts +1 -1
  22. package/packages/cli/src/teams-mcp.ts +59 -2
  23. package/packages/codex/package.json +1 -1
  24. package/packages/codex/src/paths.ts +3 -3
  25. package/packages/node/package.json +1 -1
  26. package/packages/node/src/config.ts +10 -2
  27. package/packages/node/src/opencode-warm.ts +27 -8
  28. package/packages/node/src/run.ts +3 -2
  29. package/packages/preset/package.json +1 -1
  30. package/packages/protocol/package.json +1 -1
  31. package/packages/protocol/src/index.ts +7 -1
  32. package/packages/protocol/src/launch.ts +14 -0
  33. package/packages/runtime-openshell/package.json +1 -1
  34. package/packages/runtime-sdk/package.json +1 -1
  35. package/packages/sdk/package.json +1 -1
  36. package/packages/sdk/src/errors.ts +1 -0
  37. package/packages/sdk/src/index.ts +1 -0
  38. package/packages/sdk/src/types.ts +22 -2
  39. package/packages/sdk/src/wake.ts +20 -0
  40. package/packages/spoke-sdk/package.json +1 -1
  41. package/src/central/agent/acting.ts +3 -0
  42. package/src/central/agent/audit.ts +1 -1
  43. package/src/central/agent/decisions.ts +58 -2
  44. package/src/central/agent/delivery.ts +101 -27
  45. package/src/central/agent/errors.ts +2 -0
  46. package/src/central/agent/escalation.ts +10 -2
  47. package/src/central/agent/events.ts +7 -1
  48. package/src/central/agent/launch-engine.ts +173 -30
  49. package/src/central/agent/launch-routes.ts +20 -9
  50. package/src/central/agent/launch-spec.ts +26 -2
  51. package/src/central/agent/launch-store.ts +55 -2
  52. package/src/central/agent/message-limits.ts +89 -11
  53. package/src/central/agent/message-routes.ts +8 -3
  54. package/src/central/agent/messages.ts +3 -2
  55. package/src/central/agent/placement.ts +93 -18
  56. package/src/central/agent/read-routes.ts +3 -0
  57. package/src/central/agent/routes.ts +4 -2
  58. package/src/central/agent/webhooks.ts +17 -2
  59. package/src/central/artifacts/jobs-routes.ts +4 -1
  60. package/src/central/concierge/board-routes.ts +13 -2
  61. package/src/central/concierge/inbound.ts +4 -7
  62. package/src/central/concierge/prompt.ts +4 -3
  63. package/src/central/concierge/proposals.ts +8 -2
  64. package/src/central/concierge/routes.ts +62 -2
  65. package/src/central/concierge/session-routes.ts +62 -6
  66. package/src/central/concierge/supervisor.ts +34 -7
  67. package/src/central/concierge/watchlist-routes.ts +4 -2
  68. package/src/central/config.ts +21 -5
  69. package/src/central/http.ts +38 -15
  70. package/src/central/main.ts +1 -1
  71. package/src/central/registry.ts +26 -1
  72. package/src/central/teams/intake.ts +20 -6
  73. package/src/central/teams/staffing.ts +22 -0
  74. package/src/central/workspaces/sidechats-routes.ts +1 -1
  75. package/src/index.ts +39 -18
  76. package/src/migrate.ts +109 -3
  77. package/src/spoke/env-inject.ts +8 -1
  78. package/src/spoke/hub-tool.ts +24 -10
  79. package/src/spoke/launchers.ts +6 -4
  80. package/src/spoke/listing.ts +18 -6
  81. package/src/spoke/localhub.ts +14 -4
  82. package/src/spoke/locations.ts +40 -0
  83. package/src/spoke/session-context.ts +107 -27
  84. package/src/spoke/spoke.ts +13 -5
  85. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  86. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  87. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  88. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  89. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  90. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  91. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  92. package/web/dist/assets/index-BabrNDB3.css +1 -0
  93. package/web/dist/assets/index-lIOfSYck.js +34 -0
  94. package/web/dist/assets/live-BDq0AABg.js +5 -0
  95. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  96. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DAr4IHM8.js} +1 -1
  97. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-DWZj595u.js} +1 -1
  98. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-JUb6OeI3.js} +1 -1
  99. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  100. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  101. package/web/dist/index.html +4 -4
  102. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  103. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  104. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  105. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  106. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  107. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  108. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  109. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  110. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  111. package/web/dist/assets/live-YyTjE47g.js +0 -5
  112. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "private": true,
5
5
  "description": "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -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_* and `container` only. */
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
+ }
@@ -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.slice(0, 200)}` : `the hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${url.slice(0, 200)}`)
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()
@@ -31,6 +31,7 @@ const EXIT_OF: Record<string, number> = {
31
31
  rate_limited: EXIT.rateLimited,
32
32
  unsupported: EXIT.unavailable,
33
33
  offline: EXIT.unavailable,
34
+ busy: EXIT.unavailable,
34
35
  gone: EXIT.gone,
35
36
  unauthorized: EXIT.unauthorized,
36
37
  token_expired: EXIT.unauthorized,
@@ -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
- /** `lch_1 running @api ses_1 /repo (branch) app=brain` */
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
- return [l.id, stateText(l), sessionText(l) || undefined, where, labelsText(l.spec.labels) || undefined].filter(Boolean).join(" ")
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.by) lines.push(`by: ${l.by}`)
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).slice(0, 200)}` : `the hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${String(options.server).slice(0, 200)}`)
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
@@ -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
- // and the Concierge watchlist (watchlist_get, watchlist_update, watchlist_watch, watchlist_mute: watchlist.ts).
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,7 @@ 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 { ProfileArgError, profileLifecycleTools } from "./profiles-mcp"
27
30
  import { watchlistTools } from "./watchlist"
28
31
 
29
32
  /** The protocol revisions the server speaks, newest first: a client's supported one is echoed, else the newest. */
@@ -205,7 +208,7 @@ function inboxText(box: Inbox): string {
205
208
  /** One message (message_show): its state, the message and, once answered or declined, the answer, in envelopes. */
206
209
  function messageText({ message, answer }: Asked): string {
207
210
  // (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("dropped")}` : ""}`, envelope(message)]
211
+ const lines = [NOT_FROM_USER, `${message.kind} ${message.id}: ${message.state}${message.delivery === "dropped" || message.delivery === "expired" ? ` · ${deliveryText(message.delivery)}` : ""}`, envelope(message)]
209
212
  if (answer) lines.push(`${message.state} by ${answer.id}:`, envelope(answer))
210
213
  return lines.join("\n")
211
214
  }
@@ -732,6 +735,7 @@ const TOOLS: readonly Tool[] = [
732
735
  eventsTool,
733
736
  ...webhookTools,
734
737
  ...profileTools,
738
+ ...profileLifecycleTools,
735
739
  ...workspaceTools,
736
740
  ...artifactTools,
737
741
  ...artifactJobTools,
@@ -1139,7 +1143,7 @@ export function createMcpServer(options: McpServerOptions): McpServer {
1139
1143
  return controller.signal.aborted ? undefined : ok(id, toolResult(clean(text, token), false))
1140
1144
  } catch (cause) {
1141
1145
  if (controller.signal.aborted) return undefined // cancelled: no answer (MCP cancellation)
1142
- const text = cause instanceof ArgError ? `invalid: ${cause.message}` : cause instanceof CliError ? errorText(cause) : `${tool.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`
1146
+ const text = cause instanceof ArgError || cause instanceof ProfileArgError ? `invalid: ${cause.message}` : cause instanceof CliError ? errorText(cause) : `${tool.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`
1143
1147
  return ok(id, toolResult(clean(text, token), true))
1144
1148
  } finally {
1145
1149
  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 = ({ message, delivery }: Sent): string => `${message.kind} ${message.id} to ${toText(message)}: ${deliveryText(delivery)}`
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
@@ -0,0 +1,301 @@
1
+ // MCP tools for the profile lifecycle (FX-03, m8sthu85): show a profile and its versions, create one, edit its
2
+ // metadata, write new versions (whole content, a rollback, or one skill or command attached/detached), archive and
3
+ // restore it, and pin a version to a session. The read tools a session uses on its own pin (profile, profile_skill,
4
+ // profile_command) and the listing (profiles, profile_usage) stay in mcp.ts.
5
+ //
6
+ // Profiles are immutable versioned bundles (HWA-14, RP-08): nothing here edits a version. Every content change is
7
+ // POST /profiles/:id/versions with the profile's revision; attaching or detaching a skill or command reads the latest
8
+ // version and writes its content with that one change as the next version (a stale revision answers conflict, so a
9
+ // concurrent change is never overwritten). No server behaviour is added: each tool is one existing route of
10
+ // src/central/profiles/routes.ts (plus the reads it needs).
11
+ //
12
+ // Annotations for gateway review (packages/cli/src/mcp.ts maps them): reads are readOnlyHint; create, metadata edits
13
+ // and new versions only reorganize (destructiveHint false: pinned sessions keep their explicit version, and an old
14
+ // version can be restored with fromVersion); archive/restore (no new versions or pins) and pins (applied to a running
15
+ // session) keep the default destructiveHint. Changes send expectedRevision, so repeating one is idempotent.
16
+ import { sanitizeText } from "../../sdk/src/envelope.ts"
17
+ import type { Profiles } from "../../sdk/src/client.ts"
18
+ import type { ProfileCommand, ProfileContent, ProfileSkill, ProfileVersionSummary, ProfileView } from "../../sdk/src/types.ts"
19
+
20
+ type Args = Record<string, unknown>
21
+
22
+ /** The shape of an MCP tool (as packages/cli/src/mcp.ts registers them). */
23
+ export interface ProfileTool {
24
+ readonly name: string
25
+ readonly description: string
26
+ readonly properties: Record<string, unknown>
27
+ readonly required?: readonly string[]
28
+ readonly readOnly: boolean
29
+ readonly organizes?: boolean
30
+ readonly idempotent?: boolean
31
+ run(hub: { readonly profiles: Profiles }, args: Args, signal: AbortSignal): Promise<string>
32
+ }
33
+
34
+ const STRING = { type: "string" } as const
35
+ const STRINGS = { type: "array", items: STRING } as const
36
+ const ID = { type: "string", description: "The profile's ID (prof_…; see profiles)." } as const
37
+ const REVISION = { type: "integer", minimum: 0, description: "The profile's revision you read (profile_show); a stale one answers conflict with the current revision." } as const
38
+ const IDEMPOTENCY = { type: "string", description: "Idempotency-Key: the same key and arguments within 24 h answer the first result again." } as const
39
+ const SKILL = { type: "object", properties: { name: { ...STRING, description: "A slug." }, description: STRING, body: { ...STRING, description: "The skill's full text (loaded on demand)." } }, required: ["name", "description", "body"] } as const
40
+ const COMMAND = { type: "object", properties: { name: { ...STRING, description: "A slug." }, description: STRING, template: { ...STRING, description: "Text with {{name}} parameters and {{secret:NAME}} references (never resolved by the hub)." } }, required: ["name", "description", "template"] } as const
41
+ const CONTENT_FIELDS = {
42
+ instructions: { type: ["string", "null"], description: "The profile's instructions (AGENTS.md-like text)." },
43
+ model: { type: ["string", "null"], description: "provider/model the profile suggests." },
44
+ skills: { type: "array", items: SKILL, description: "Skills, wholesale." },
45
+ commands: { type: "array", items: COMMAND, description: "Commands, wholesale." },
46
+ } as const
47
+ const META_FIELDS = {
48
+ title: { ...STRING, description: "1–120 characters." },
49
+ description: STRING,
50
+ tags: { ...STRINGS, description: "Slugs." },
51
+ metadata: { type: "object", additionalProperties: STRING, description: "String key/value pairs (≤ 32 slug keys)." },
52
+ projects: { ...STRINGS, description: "Project names the profile is meant for (≤ 16)." },
53
+ } as const
54
+ const SCOPES = "App tokens need scope profiles:read (reads) or profiles:manage (changes; profile_version_create with content fields and the skill/command set/remove tools read the latest version first, so they also need profiles:read); pins need profiles:assign and sessions:read (an app also needs a prompt grant on the session). Session tokens never change profiles. A profile confers no authority."
55
+
56
+ /** A bad tool argument: mcp.ts answers it as `invalid: …` (an isError result). */
57
+ export class ProfileArgError extends Error {}
58
+ const ArgError = ProfileArgError
59
+
60
+ const need = (args: Args, key: string): string => {
61
+ const value = args[key]
62
+ if (typeof value !== "string" || !value.trim()) throw new ArgError(`"${key}" is required (a non-empty string)`)
63
+ return value
64
+ }
65
+ const optional = (args: Args, key: string): string | undefined => {
66
+ const value = args[key]
67
+ if (value === undefined || value === null) return undefined
68
+ if (typeof value !== "string") throw new ArgError(`"${key}" must be a string`)
69
+ return value.trim() ? value : undefined
70
+ }
71
+ const whole = (args: Args, key: string, min: number, what: string): number => {
72
+ const value = args[key]
73
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) throw new ArgError(`"${key}" is required: ${what}`)
74
+ return value
75
+ }
76
+ const revision = (args: Args): number => whole(args, "expectedRevision", 0, "the revision you read (profile_show; 0 for a session with no pin)")
77
+ const idempotency = (args: Args) => {
78
+ const key = optional(args, "idempotencyKey")
79
+ return key ? { idempotencyKey: key } : {}
80
+ }
81
+ /** The given fields of `keys` (undefined ones dropped). */
82
+ const picked = (args: Args, keys: readonly string[]): Record<string, unknown> => Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]))
83
+
84
+ const one = (text: string) => sanitizeText(text).replace(/\s+/g, " ")
85
+
86
+ /** `prof_… name vN rN state title` */
87
+ export const profileLine = (p: ProfileView): string => `${p.id} ${p.name} v${p.latestVersion} r${p.revision} ${p.state} ${one(p.title)}`
88
+
89
+ /** A version's contents by name (never bodies). */
90
+ function summaryLines(v: ProfileVersionSummary | undefined): string[] {
91
+ if (!v) return []
92
+ return [
93
+ `version ${v.version} (${v.digest.slice(0, 12)}, ${v.bytes} bytes): instructions ${v.instructions ? "yes" : "no"}${v.model ? `, model ${one(v.model)}` : ""}`,
94
+ `skills: ${v.skills.length ? v.skills.map((s) => s.name).join(", ") : "none"}`,
95
+ `commands: ${v.commands.length ? v.commands.map((c) => `${c.name}(${c.params.join(", ")})`).join(", ") : "none"}`,
96
+ ...(v.secretRefs.length ? [`secret references: ${v.secretRefs.join(", ")}`] : []),
97
+ ]
98
+ }
99
+
100
+ const versionText = (r: { profile: ProfileView; version: ProfileVersionSummary }) => [`new version ${r.version.version}: ${profileLine(r.profile)}`, ...summaryLines(r.version)].join("\n")
101
+
102
+ /** The content of a profile's latest version (what an attach/detach starts from). */
103
+ async function latestContent(profiles: Profiles, id: string, signal: AbortSignal): Promise<{ latest: number; content: ProfileContent }> {
104
+ const { profile } = await profiles.get(id, { signal })
105
+ const { version } = await profiles.version(profile.id, profile.latestVersion, { signal })
106
+ return { latest: version.version, content: version.content }
107
+ }
108
+
109
+ /** A tool writing the latest version's content with one change as the next version. */
110
+ const contentTool = (
111
+ name: string,
112
+ description: string,
113
+ properties: Record<string, unknown>,
114
+ required: readonly string[],
115
+ change: (content: ProfileContent, args: Args, latest: number) => ProfileContent,
116
+ ): ProfileTool => ({
117
+ name,
118
+ description: `${description} Writes the latest version's content with this change as the next version (versions are immutable; pinned sessions keep theirs). ${SCOPES}`,
119
+ properties: { id: ID, ...properties, expectedRevision: REVISION },
120
+ required: ["id", ...required, "expectedRevision"],
121
+ readOnly: false,
122
+ organizes: true,
123
+ idempotent: true,
124
+ run: async (hub, args, signal) => {
125
+ const expectedRevision = revision(args)
126
+ const id = need(args, "id")
127
+ const { latest, content } = await latestContent(hub.profiles, id, signal)
128
+ return versionText(await hub.profiles.createVersion(id, { expectedRevision, content: change(content, args, latest) }, { signal }))
129
+ },
130
+ })
131
+
132
+ /** `items` with `item` replacing the one of its name (in place), else appended. */
133
+ const setNamed = <T extends { readonly name: string }>(items: readonly T[], item: T): T[] =>
134
+ items.some((i) => i.name === item.name) ? items.map((i) => (i.name === item.name ? item : i)) : [...items, item]
135
+ const removeNamed = <T extends { readonly name: string }>(items: readonly T[], name: string, what: string, latest: number): T[] => {
136
+ if (!items.some((i) => i.name === name)) throw new ArgError(`version ${latest} has no ${what} ${name}`)
137
+ return items.filter((i) => i.name !== name)
138
+ }
139
+
140
+ const stateTool = (verb: "archive" | "restore", description: string): ProfileTool => ({
141
+ name: `profile_${verb}`,
142
+ description: `${description} ${SCOPES}`,
143
+ properties: { id: ID, expectedRevision: REVISION },
144
+ required: ["id", "expectedRevision"],
145
+ readOnly: false,
146
+ idempotent: true,
147
+ run: async (hub, args, signal) => {
148
+ const expectedRevision = revision(args)
149
+ const { profile } = await hub.profiles.update(need(args, "id"), { expectedRevision, state: verb === "archive" ? "archived" : "active" }, { signal })
150
+ return `${verb === "archive" ? "archived" : "restored"}: ${profileLine(profile)}`
151
+ },
152
+ })
153
+
154
+ export const profileLifecycleTools: readonly ProfileTool[] = [
155
+ // ---------- reads ----------
156
+ {
157
+ name: "profile_show",
158
+ description: `One profile: name, title, state, revision (send it as expectedRevision), tags, projects, metadata, and its latest version's instructions, model, skill and command names (never bodies). ${SCOPES}`,
159
+ properties: { id: ID },
160
+ required: ["id"],
161
+ readOnly: true,
162
+ run: async (hub, args, signal) => {
163
+ const { profile, latest } = await hub.profiles.get(need(args, "id"), { signal })
164
+ return [
165
+ profileLine(profile),
166
+ ...(profile.description ? [`description: ${one(profile.description)}`] : []),
167
+ ...(profile.tags?.length ? [`tags: ${profile.tags.join(", ")}`] : []),
168
+ ...(profile.projects?.length ? [`projects: ${profile.projects.map(one).join(", ")}`] : []),
169
+ ...(profile.metadata && Object.keys(profile.metadata).length ? [`metadata: ${Object.entries(profile.metadata).map(([k, v]) => `${k}=${one(v)}`).join(", ")}`] : []),
170
+ ...summaryLines(latest),
171
+ ].join("\n")
172
+ },
173
+ },
174
+ {
175
+ name: "profile_versions",
176
+ description: `A profile's retained versions, newest first (version, digest, bytes, when, skill and command counts). ${SCOPES}`,
177
+ properties: { id: ID, limit: { type: "integer", minimum: 1, maximum: 200 }, cursor: STRING },
178
+ required: ["id"],
179
+ readOnly: true,
180
+ run: async (hub, args, signal) => {
181
+ const limit = args.limit === undefined ? undefined : whole(args, "limit", 1, "a whole number from 1 to 200")
182
+ const cursor = optional(args, "cursor")
183
+ const page = await hub.profiles.versions(need(args, "id"), { signal, ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}) })
184
+ const counts = (v: (typeof page.items)[number]) => (v.skills !== undefined ? ` ${v.skills} skills, ${v.commands ?? 0} commands${v.instructions ? ", instructions" : ""}` : "")
185
+ return [...page.items.map((v) => `v${v.version} ${v.digest.slice(0, 12)} ${v.bytes} bytes ${new Date(v.createdAt).toISOString()}${counts(v)}`), ...(page.cursor ? [`more: cursor ${page.cursor}`] : [])].join("\n") || "no versions"
186
+ },
187
+ },
188
+
189
+ // ---------- definitions ----------
190
+ {
191
+ name: "profile_create",
192
+ description: `Create a profile: a versioned bundle of instructions, skills and commands, published as version 1 at once. The name is unique per user. Attach it to a team role with team_role_set (profile), pin it with profile_assign. ${SCOPES}`,
193
+ properties: { name: { ...STRING, description: "A slug, unique per user." }, ...META_FIELDS, ...CONTENT_FIELDS, idempotencyKey: IDEMPOTENCY },
194
+ required: ["name", "title"],
195
+ readOnly: false,
196
+ organizes: true,
197
+ idempotent: true,
198
+ run: async (hub, args, signal) => {
199
+ need(args, "name")
200
+ need(args, "title")
201
+ const meta = picked(args, ["name", "title", "description", "tags", "metadata", "projects"]) as { name: string; title: string }
202
+ const text = Object.entries(picked(args, ["instructions", "model"])).filter(([, v]) => v !== null)
203
+ const content = { ...Object.fromEntries(text), skills: (args.skills ?? []) as ProfileSkill[], commands: (args.commands ?? []) as ProfileCommand[] } as ProfileContent
204
+ const r = await hub.profiles.create({ ...meta, content }, { signal, ...idempotency(args) })
205
+ // A replay of the same idempotencyKey answers the first result again (the API does not mark replays).
206
+ return [`created: ${profileLine(r.profile)}`, ...summaryLines(r.version)].join("\n")
207
+ },
208
+ },
209
+ {
210
+ name: "profile_update",
211
+ description: `Edit a profile's metadata: title, description, tags, metadata, projects (null clears description, metadata or projects). Content changes are new versions (profile_version_create, profile_skill_set, …). ${SCOPES}`,
212
+ properties: { id: ID, ...META_FIELDS, description: { type: ["string", "null"] }, metadata: { ...META_FIELDS.metadata, type: ["object", "null"] }, projects: { ...META_FIELDS.projects, type: ["array", "null"] }, expectedRevision: REVISION },
213
+ required: ["id", "expectedRevision"],
214
+ readOnly: false,
215
+ organizes: true,
216
+ idempotent: true,
217
+ run: async (hub, args, signal) => {
218
+ const expectedRevision = revision(args)
219
+ const patch = picked(args, ["title", "description", "tags", "metadata", "projects"])
220
+ if (Object.keys(patch).length === 0) throw new ArgError("give at least one field to change")
221
+ const { profile } = await hub.profiles.update(need(args, "id"), { expectedRevision, ...patch }, { signal })
222
+ return `updated: ${profileLine(profile)}`
223
+ },
224
+ },
225
+ {
226
+ name: "profile_version_create",
227
+ description: `Write a profile's next version: either content fields (instructions, model, skills, commands; the ones you omit are kept from the latest version, null clears instructions or model) or fromVersion (an older version's content again: an explicit rollback). Versions are immutable; pinned sessions keep theirs until re-pinned. ${SCOPES}`,
228
+ properties: { id: ID, ...CONTENT_FIELDS, fromVersion: { type: "integer", minimum: 1, description: "A retained version to restore as the next one." }, expectedRevision: REVISION, idempotencyKey: IDEMPOTENCY },
229
+ required: ["id", "expectedRevision"],
230
+ readOnly: false,
231
+ organizes: true,
232
+ idempotent: true,
233
+ run: async (hub, args, signal) => {
234
+ const expectedRevision = revision(args)
235
+ const id = need(args, "id")
236
+ const given = Object.keys(picked(args, Object.keys(CONTENT_FIELDS)))
237
+ const options = { signal, ...idempotency(args) }
238
+ if (args.fromVersion !== undefined) {
239
+ if (given.length) throw new ArgError(`give either fromVersion or content fields (${given.join(", ")}), not both`)
240
+ return versionText(await hub.profiles.createVersion(id, { expectedRevision, fromVersion: whole(args, "fromVersion", 1, "a version number") }, options))
241
+ }
242
+ if (!given.length) throw new ArgError("give content fields (instructions, model, skills, commands) or fromVersion")
243
+ const { content } = await latestContent(hub.profiles, id, signal)
244
+ const next: Record<string, unknown> = { ...content }
245
+ for (const key of given) {
246
+ if (args[key] === null) delete next[key]
247
+ else next[key] = args[key]
248
+ }
249
+ return versionText(await hub.profiles.createVersion(id, { expectedRevision, content: next as unknown as ProfileContent }, options))
250
+ },
251
+ },
252
+ contentTool("profile_skill_set", "Attach a skill to a profile, or replace the skill of that name.", { name: { ...STRING, description: "The skill's name (a slug)." }, description: STRING, body: { ...STRING, description: "The skill's full text." } }, ["name", "description", "body"], (content, args) => ({
253
+ ...content,
254
+ skills: setNamed(content.skills, { name: need(args, "name"), description: need(args, "description"), body: need(args, "body") }),
255
+ })),
256
+ contentTool("profile_skill_remove", "Detach a skill from a profile.", { name: { ...STRING, description: "The skill's name." } }, ["name"], (content, args, latest) => ({
257
+ ...content,
258
+ skills: removeNamed(content.skills, need(args, "name"), "skill", latest),
259
+ })),
260
+ contentTool(
261
+ "profile_command_set",
262
+ "Attach a command to a profile, or replace the command of that name.",
263
+ { name: { ...STRING, description: "The command's name (a slug)." }, description: STRING, template: COMMAND.properties.template },
264
+ ["name", "description", "template"],
265
+ (content, args) => ({ ...content, commands: setNamed(content.commands, { name: need(args, "name"), description: need(args, "description"), template: need(args, "template") }) }),
266
+ ),
267
+ contentTool("profile_command_remove", "Detach a command from a profile.", { name: { ...STRING, description: "The command's name." } }, ["name"], (content, args, latest) => ({
268
+ ...content,
269
+ commands: removeNamed(content.commands, need(args, "name"), "command", latest),
270
+ })),
271
+ stateTool("archive", "Archive a profile: it takes no new versions or pins; pinned sessions keep theirs; reversible with profile_restore."),
272
+ stateTool("restore", "Restore an archived profile (it takes versions and pins again)."),
273
+
274
+ // ---------- pins ----------
275
+ {
276
+ name: "profile_assign",
277
+ description: `Pin a profile version to a session (applied through its machine; the ack tells whether it took). Send the session's pin revision (0 when it has none; see profile). Pinning confers no authority. ${SCOPES}`,
278
+ properties: { addr: { ...STRING, description: "The session: @name, ses_… ID or other address." }, profileID: ID, version: { type: "integer", minimum: 1 }, expectedRevision: { ...REVISION, description: "The session's pin revision (0 when it has none)." } },
279
+ required: ["addr", "profileID", "version", "expectedRevision"],
280
+ readOnly: false,
281
+ idempotent: true,
282
+ run: async (hub, args, signal) => {
283
+ const expectedRevision = revision(args)
284
+ const { assignment: a } = await hub.profiles.assign(need(args, "addr"), { profileID: need(args, "profileID"), version: whole(args, "version", 1, "a version number"), expectedRevision }, { signal })
285
+ return `${a.sessionID} pins ${a.profileID} v${a.version} r${a.revision}${a.ack ? `, ack ${a.ack.status}${a.ack.offline ? " (offline)" : ""}` : ""}`
286
+ },
287
+ },
288
+ {
289
+ name: "profile_unassign",
290
+ description: `Remove a session's pin (what it already applied stays until it restarts). Send the pin's revision (profile_assign's answer). ${SCOPES}`,
291
+ properties: { addr: { ...STRING, description: "The session." }, expectedRevision: { ...REVISION, description: "The session's pin revision." } },
292
+ required: ["addr", "expectedRevision"],
293
+ readOnly: false,
294
+ idempotent: true,
295
+ run: async (hub, args, signal) => {
296
+ const addr = need(args, "addr")
297
+ await hub.profiles.unassign(addr, revision(args), { signal })
298
+ return `unpinned ${addr}`
299
+ },
300
+ },
301
+ ]
@@ -56,7 +56,8 @@ import { TxStore } from "../bootstrap/transaction/store.ts"
56
56
  import type { TxOutcome } from "../bootstrap/transaction/engine.ts"
57
57
  import { readNodeConfig, nodeConfigPath } from "../../../node/src/config.ts"
58
58
  import { str, UsageError, type FlagKind, type Parsed } from "../args"
59
- import { resolveCredentials, type Env } from "../credentials"
59
+ import { resolveCredentials, type Credentials, type Env } from "../credentials"
60
+ import { isInsecureStoredURL, storedInsecureWarning } from "../bootstrap/enrollment/policy.ts"
60
61
  import { clientFor } from "../http"
61
62
  import { hubLaunchDriver } from "./launch-driver.ts"
62
63
  import { errorCodeOf, issuesText, PlanRefusal } from "./contributors.ts"
@@ -214,11 +215,28 @@ function readState(snapshot: DiscoverySnapshot): BootstrapState {
214
215
  }
215
216
  }
216
217
 
218
+ /**
219
+ * This shell's hub login for the verification launch; a stored plain-http, non-loopback login is warned about once
220
+ * on stderr, as every `hub` command does.
221
+ */
222
+ export function launchCredentials(io: Pick<SetupIo, "env" | "home" | "stderr">): () => Credentials | undefined {
223
+ let warned = false
224
+ return () => {
225
+ const found = resolveCredentials(io.env as Env, io.home)
226
+ if (found?.source === "login" && !warned && isInsecureStoredURL(found.url)) {
227
+ warned = true
228
+ io.stderr(`hubctl: warning: ${storedInsecureWarning(found.url)}\n`)
229
+ }
230
+ return found
231
+ }
232
+ }
233
+
217
234
  /** The verification launch over the hub launch API, with this shell's hub login (resolved when it starts). */
218
235
  function launchDriverFor(io: SetupIo) {
219
236
  const store = credentialStoreFor(io.env, io.home)
237
+ const login = launchCredentials(io)
220
238
  const client = () => {
221
- const credentials = resolveCredentials(io.env as Env, io.home)
239
+ const credentials = login()
222
240
  if (!credentials) throw new Error("not logged in to the hub (run `hub login --scope sessions:launch`; an app token launches under a grant with verb launch)")
223
241
  return clientFor(credentials)
224
242
  }
@@ -18,7 +18,7 @@ export type ServiceVerb = (typeof SERVICE_VERBS)[number]
18
18
  export const isServiceVerb = (verb: string | undefined): verb is ServiceVerb => (SERVICE_VERBS as readonly string[]).includes(verb ?? "")
19
19
 
20
20
  /** Where config.json and the hub state dir are (shared by every hub spoke). */
21
- const HUB_PLACES = ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR"] as const
21
+ const HUB_PLACES = ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR", "XDG_STATE_HOME"] as const
22
22
 
23
23
  export const CLAUDE_CODE_SERVICE: ServiceSpec = {
24
24
  service: "claude-code",