@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
|
@@ -57,6 +57,63 @@ const SPEC_FIELDS = {
|
|
|
57
57
|
metadata: { type: "object", additionalProperties: STRING, description: "String key/value pairs (keys are slugs)." },
|
|
58
58
|
projects: { ...STRINGS, description: "Project names the team works on." },
|
|
59
59
|
} as const
|
|
60
|
+
// (FX-03, l3vynemo) The launch spec as validateLaunchSpec (src/central/agent/launch-spec.ts) takes it: NESTED sections,
|
|
61
|
+
// unlike the plain launch tool's flat fields. Keep the two in step. No additionalProperties: false, so a flat field
|
|
62
|
+
// reaches the hub, whose answer names the nested place it belongs (a client-side schema refusal would not).
|
|
63
|
+
const LAUNCH = {
|
|
64
|
+
type: "object",
|
|
65
|
+
description:
|
|
66
|
+
"A launch spec (nested, as POST /api/agent/launches takes; unlike the launch tool's flat fields): workspace{repo, mode, branch, base, remote} is required; placement{machine, labels, mode}, runtime{kind, profile}, end{when, timeout, cleanup} and the top-level fields are optional; key is set by staffing (send idempotencyKey instead). The role's prefs fill harness, model and placement.machine, and its profile fills agentProfile, when not given. A flat field (repo, machine, cleanup at the top) is refused with the nested place it belongs.",
|
|
67
|
+
properties: {
|
|
68
|
+
workspace: {
|
|
69
|
+
type: "object",
|
|
70
|
+
description: "Where the member works on its machine.",
|
|
71
|
+
properties: {
|
|
72
|
+
repo: { ...STRING, description: "The repository's absolute path on the machine." },
|
|
73
|
+
mode: { type: "string", enum: ["worktree", "checkout", "clone"], description: "worktree (default; needs branch), checkout, or clone (needs remote)." },
|
|
74
|
+
branch: { ...STRING, description: "The branch to create or check out (a safe git branch name; required for worktree)." },
|
|
75
|
+
base: { ...STRING, description: "What a new branch starts from." },
|
|
76
|
+
remote: { ...STRING, description: "An https or ssh git remote, without credentials (required for clone)." },
|
|
77
|
+
},
|
|
78
|
+
required: ["repo"],
|
|
79
|
+
},
|
|
80
|
+
placement: {
|
|
81
|
+
type: "object",
|
|
82
|
+
description: "Which machine runs it.",
|
|
83
|
+
properties: {
|
|
84
|
+
machine: { ...STRING, description: "A machine ID or name." },
|
|
85
|
+
labels: { type: "object", additionalProperties: STRING, description: "Node labels to match." },
|
|
86
|
+
mode: { type: "string", enum: ["require", "prefer"], description: "require (default) or prefer the machine/labels." },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
runtime: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: {
|
|
92
|
+
kind: { type: "string", enum: ["host", "openshell"], description: "host (default) or openshell (clone workspaces only)." },
|
|
93
|
+
profile: { ...STRING, description: "An openshell sandbox profile (openshell only)." },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
end: {
|
|
97
|
+
type: "object",
|
|
98
|
+
description: "When the launch ends.",
|
|
99
|
+
properties: {
|
|
100
|
+
when: { type: "string", enum: ["idle", "asked", "never"], description: "idle (default), asked or never." },
|
|
101
|
+
timeout: { type: ["string", "number"], description: "1 minute to 30 days: seconds, or 30m, 4h, 2d." },
|
|
102
|
+
cleanup: { type: "string", enum: ["keep", "remove"], description: "keep (default) or remove the workspace at the end." },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
name: { ...STRING, description: "The session's @name, without the @." },
|
|
106
|
+
roles: { ...STRINGS, description: "Roles the session claims (up to 10)." },
|
|
107
|
+
harness: { ...STRING, description: "The harness (default opencode)." },
|
|
108
|
+
agent: { ...STRING, description: "The harness agent." },
|
|
109
|
+
model: { ...STRING, description: "provider/model." },
|
|
110
|
+
prompt: { ...STRING, description: "The first prompt (up to 10000 characters)." },
|
|
111
|
+
intent: { ...STRING, description: "What the session is for (one line)." },
|
|
112
|
+
labels: { type: "object", additionalProperties: STRING, description: "Free-form app labels." },
|
|
113
|
+
agentProfile: { type: "object", properties: { profileID: STRING, version: { type: "integer", minimum: 1 } }, required: ["profileID", "version"], description: "The profile version to pin (needs profiles:assign for an app)." },
|
|
114
|
+
},
|
|
115
|
+
required: ["workspace"],
|
|
116
|
+
} as const
|
|
60
117
|
const SCOPES = "App tokens need scope teams:read (reads) or teams:manage (changes); seats, status and staffing also need sessions:read. Session tokens read only teams they are seated in and never change definitions. Team roles confer no authority."
|
|
61
118
|
|
|
62
119
|
class ArgError extends Error {}
|
|
@@ -352,7 +409,7 @@ export const teamTools: readonly TeamTool[] = [
|
|
|
352
409
|
{
|
|
353
410
|
name: "team_staff",
|
|
354
411
|
description: `Staff a role: attach an existing session (sessionID, any machine) or launch a new member (launch: a launch spec as the launch tool takes). An agent needs the user's grants (roles for the session's project; launch for a repo) or the hub answers needs_user; staffing never issues grants. ${SCOPES}`,
|
|
355
|
-
properties: { team: TEAM, role: STRING, sessionID: { ...STRING, description: "An existing session (ses_…)." }, launch:
|
|
412
|
+
properties: { team: TEAM, role: STRING, sessionID: { ...STRING, description: "An existing session (ses_…)." }, launch: LAUNCH, idempotencyKey: IDEMPOTENCY },
|
|
356
413
|
required: ["team", "role"],
|
|
357
414
|
readOnly: false,
|
|
358
415
|
run: async (hub, args, signal) => {
|
|
@@ -386,7 +443,7 @@ export const teamTools: readonly TeamTool[] = [
|
|
|
386
443
|
{
|
|
387
444
|
name: "team_replace",
|
|
388
445
|
description: `Replace a seat by an existing session (sessionID) or a new launch (launch): the old seat ends at once and the new member gets the slot's next generation. Send the seat's revision (team_status). ${SCOPES}`,
|
|
389
|
-
properties: { team: TEAM, staffingID: STRING, sessionID: STRING, launch:
|
|
446
|
+
properties: { team: TEAM, staffingID: STRING, sessionID: STRING, launch: LAUNCH, expectedRevision: { ...REVISION, description: "The seat's revision you read (team_status)." } },
|
|
390
447
|
required: ["team", "staffingID", "expectedRevision"],
|
|
391
448
|
readOnly: false,
|
|
392
449
|
run: async (hub, args, signal) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-codex",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// Where the Codex spoke looks and keeps its files. It only reads Codex's home (CODEX_HOME, default ~/.codex) to find
|
|
2
2
|
// the shared daemon's socket; its own files live in <hub state dir>/codex (the hub state dir is $HUB_STATE_DIR, else
|
|
3
|
-
// ~/.local/state/hub), a private (0700) directory holding the service's pid file (spoke.pid) and its log.
|
|
3
|
+
// $XDG_STATE_HOME/hub or ~/.local/state/hub), a private (0700) directory holding the service's pid file (spoke.pid) and its log.
|
|
4
4
|
// $HUB_CODEX_DAEMON_SOCKET names another daemon socket, or "off" for none.
|
|
5
5
|
import { chmodSync, lstatSync, mkdirSync, readFileSync } from "node:fs"
|
|
6
6
|
import { homedir } from "node:os"
|
|
7
7
|
import path from "node:path"
|
|
8
8
|
import { hubEnv, type Env } from "../../../src/env.ts"
|
|
9
|
-
import {
|
|
9
|
+
import { resolveHubStateDir } from "../../../src/migrate.ts"
|
|
10
10
|
|
|
11
11
|
export function codexHome(env: Env = process.env, home: string = homedir()): string {
|
|
12
12
|
return env.CODEX_HOME || path.join(home, ".codex")
|
|
@@ -20,7 +20,7 @@ export function daemonSocketPath(env: Env = process.env, home: string = homedir(
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function codexStateDir(env: Env = process.env, home: string = homedir()): string {
|
|
23
|
-
return path.join(
|
|
23
|
+
return path.join(resolveHubStateDir(env, home).dir, "codex")
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export const pidFile = (env: Env = process.env, home: string = homedir()) => path.join(codexStateDir(env, home), "spoke.pid")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-node",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// `opencode` (RF-04, opencode-warm.ts): foreground `opencode serve` processes to keep warm besides the background
|
|
14
14
|
// service (loopback URLs only, the password from a private file), and the project directories to keep loaded on the
|
|
15
15
|
// service (`directories`) or on a server (its own `directories`); absent: the projects of recent sessions under
|
|
16
|
-
// workspaceRoots.
|
|
16
|
+
// workspaceRoots. `opencode.timeoutSeconds` (1–300, default 30) bounds each warm request.
|
|
17
17
|
import { readFileSync } from "node:fs"
|
|
18
18
|
import { homedir, hostname } from "node:os"
|
|
19
19
|
import path from "node:path"
|
|
@@ -55,6 +55,8 @@ export interface OpenCodeNodeConfig {
|
|
|
55
55
|
/** Project directories the background service keeps loaded; absent: recent sessions' projects under workspaceRoots. */
|
|
56
56
|
readonly directories?: readonly string[]
|
|
57
57
|
readonly servers: readonly OpenCodeServerConfig[]
|
|
58
|
+
/** The warmer's per-request timeout in seconds (1–300; default opencode-warm.ts WARM_TIMEOUT_MS). */
|
|
59
|
+
readonly timeoutSeconds?: number
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
/** A foreground `opencode serve` the node keeps warm. */
|
|
@@ -205,7 +207,13 @@ function normalizeOpenCode(raw: unknown, problems: string[]): OpenCodeNodeConfig
|
|
|
205
207
|
problems.push(`opencode.servers: at most ${OPENCODE_SERVERS_MAX} servers; the rest are ignored`)
|
|
206
208
|
servers.length = OPENCODE_SERVERS_MAX
|
|
207
209
|
}
|
|
208
|
-
|
|
210
|
+
let timeoutSeconds: number | undefined
|
|
211
|
+
if (raw.timeoutSeconds !== undefined) {
|
|
212
|
+
const t = raw.timeoutSeconds
|
|
213
|
+
if (typeof t === "number" && Number.isInteger(t) && t >= 1 && t <= 300) timeoutSeconds = t
|
|
214
|
+
else problems.push("opencode.timeoutSeconds must be a whole number of seconds from 1 to 300")
|
|
215
|
+
}
|
|
216
|
+
return { ...(directories ? { directories } : {}), servers, ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}) }
|
|
209
217
|
}
|
|
210
218
|
|
|
211
219
|
function normalizeHarnesses(raw: unknown, problems: string[]): Record<string, boolean> | undefined {
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
// origin only. Logs on changes only.
|
|
17
17
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync, type Stats } from "node:fs"
|
|
18
18
|
import path from "node:path"
|
|
19
|
-
import { readFile } from "node:fs/promises"
|
|
20
19
|
import { listServiceSessions, type ListingClient, type RawProject, type RawSession } from "../../../src/spoke/listing.ts"
|
|
21
20
|
|
|
22
21
|
/** How often the daemon asks (a restarted server is warmed within this). */
|
|
23
22
|
export const WARM_INTERVAL_MS = 30_000
|
|
24
|
-
|
|
23
|
+
/** Per-request timeout (node.json opencode.timeoutSeconds overrides it): long enough for a busy machine's first project load. */
|
|
24
|
+
export const WARM_TIMEOUT_MS = 30_000
|
|
25
25
|
/** Project locations warmed per server and tick, besides its default location. */
|
|
26
26
|
export const WARM_DIRECTORIES_MAX = 8
|
|
27
27
|
/** Automatic project locations: sessions updated within this many days, from one page of this many. */
|
|
@@ -30,6 +30,8 @@ const SESSION_PAGE = 100
|
|
|
30
30
|
/** Loopback hosts asked as they are (after URL normalization); localhost is rewritten to 127.0.0.1. */
|
|
31
31
|
const LOOPBACK = new Set(["127.0.0.1", "[::1]"])
|
|
32
32
|
const PASSWORD_FILE_MAX = 4096
|
|
33
|
+
/** Largest service.json read (OpenCode's is a few hundred bytes). */
|
|
34
|
+
const REGISTRATION_MAX = 64 * 1024
|
|
33
35
|
|
|
34
36
|
/** A local OpenCode server (the service's registration, or a node.json server with its password). */
|
|
35
37
|
export interface WarmEndpoint {
|
|
@@ -243,23 +245,40 @@ export class OpenCodeWarmer {
|
|
|
243
245
|
|
|
244
246
|
/**
|
|
245
247
|
* OpenCode's background service from its registration file (service.json: {url, pid, password?, id?, version?}),
|
|
246
|
-
* without any request: undefined when the file is missing or malformed
|
|
247
|
-
*
|
|
248
|
+
* without any request: undefined when the file is missing or malformed, is not this user's own (a symbolic link, not
|
|
249
|
+
* owned by this uid, or writable by group or others), or its process is gone or another user's (EPERM). The URL is
|
|
250
|
+
* passed on as it is; OpenCodeWarmer.tick decides whether it may be asked.
|
|
248
251
|
*/
|
|
249
|
-
export async function readServiceRegistration(file: string): Promise<WarmEndpoint | undefined> {
|
|
252
|
+
export async function readServiceRegistration(file: string, options: { readonly uid?: number } = {}): Promise<WarmEndpoint | undefined> {
|
|
253
|
+
const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined)
|
|
250
254
|
let info: unknown
|
|
255
|
+
let fd: number | undefined
|
|
251
256
|
try {
|
|
252
|
-
|
|
257
|
+
const before = lstatSync(file)
|
|
258
|
+
if (!before.isFile()) return undefined
|
|
259
|
+
fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
260
|
+
const stat = fstatSync(fd)
|
|
261
|
+
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return undefined
|
|
262
|
+
if ((stat.mode & 0o022) !== 0) return undefined
|
|
263
|
+
if (uid !== undefined && stat.uid !== uid) return undefined
|
|
264
|
+
if (stat.size > REGISTRATION_MAX) return undefined
|
|
265
|
+
// Read through the descriptor that was checked (no second open by path).
|
|
266
|
+
const buffer = Buffer.alloc(REGISTRATION_MAX + 1)
|
|
267
|
+
const length = readSync(fd, buffer, 0, buffer.length, 0)
|
|
268
|
+
if (length > REGISTRATION_MAX) return undefined
|
|
269
|
+
info = JSON.parse(buffer.subarray(0, length).toString("utf8"))
|
|
253
270
|
} catch {
|
|
254
271
|
return undefined
|
|
272
|
+
} finally {
|
|
273
|
+
if (fd !== undefined) closeSync(fd)
|
|
255
274
|
}
|
|
256
275
|
if (!info || typeof info !== "object") return undefined
|
|
257
276
|
const { url, pid, password } = info as Record<string, unknown>
|
|
258
277
|
if (typeof url !== "string" || url.length > 2048 || typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return undefined
|
|
259
278
|
try {
|
|
260
279
|
process.kill(pid, 0)
|
|
261
|
-
} catch
|
|
262
|
-
|
|
280
|
+
} catch {
|
|
281
|
+
return undefined // gone (ESRCH), or another user's process (EPERM): not this user's service
|
|
263
282
|
}
|
|
264
283
|
return typeof password === "string" ? { url, auth: { type: "basic", username: "opencode", password } } : { url }
|
|
265
284
|
}
|
package/packages/node/src/run.ts
CHANGED
|
@@ -50,9 +50,10 @@ export const openCodeServiceFile = (env: Env, home: string): string =>
|
|
|
50
50
|
function startOpenCodeWarmers(config: NodeConfig, service: () => Promise<WarmEndpoint | undefined>, log: (line: string) => void, signal: AbortSignal): void {
|
|
51
51
|
const roots = () => config.workspaceRoots
|
|
52
52
|
const directories = config.opencode?.directories
|
|
53
|
-
|
|
53
|
+
const timeout = config.opencode?.timeoutSeconds !== undefined ? { timeoutMs: config.opencode.timeoutSeconds * 1000 } : {}
|
|
54
|
+
new OpenCodeWarmer({ discover: service, log, roots, ...timeout, ...(directories ? { directories } : {}) }).start(signal)
|
|
54
55
|
for (const server of config.opencode?.servers ?? []) {
|
|
55
|
-
new OpenCodeWarmer({ discover: serverEndpoint(server), log, name: "the OpenCode server", roots, ...(server.directories ? { directories: server.directories } : {}) }).start(signal)
|
|
56
|
+
new OpenCodeWarmer({ discover: serverEndpoint(server), log, name: "the OpenCode server", roots, ...timeout, ...(server.directories ? { directories: server.directories } : {}) }).start(signal)
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-preset",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -161,6 +161,11 @@ export interface SessionInfo {
|
|
|
161
161
|
*/
|
|
162
162
|
readonly agent?: string
|
|
163
163
|
readonly contextRevision?: number
|
|
164
|
+
/**
|
|
165
|
+
* (FX-07) The session is locked as a Concierge session on this spoke (sent from the first sessions frame after a
|
|
166
|
+
* restart, before `agent` or `contextRevision` are known). The hub treats it as a Concierge session.
|
|
167
|
+
*/
|
|
168
|
+
readonly locked?: true
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
/** Most files a revert summary or preview lists (`filesTotal` counts them all). */
|
|
@@ -911,7 +916,8 @@ function isSession(v: unknown): v is SessionInfo {
|
|
|
911
916
|
(v.terminal === undefined || isTerminal(v.terminal)) &&
|
|
912
917
|
(v.revert === undefined || isSessionRevert(v.revert)) &&
|
|
913
918
|
(v.agent === undefined || (nonEmpty(v.agent) && v.agent.length <= AGENT_NAME_MAX)) &&
|
|
914
|
-
(v.contextRevision === undefined || (Number.isSafeInteger(v.contextRevision) && (v.contextRevision as number) >= 0))
|
|
919
|
+
(v.contextRevision === undefined || (Number.isSafeInteger(v.contextRevision) && (v.contextRevision as number) >= 0)) &&
|
|
920
|
+
(v.locked === undefined || v.locked === true)
|
|
915
921
|
)
|
|
916
922
|
}
|
|
917
923
|
|
|
@@ -103,6 +103,14 @@ export interface LaunchArtifacts {
|
|
|
103
103
|
readonly dirty?: boolean
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Why a queued launch waits (FX-02): capacity (a fitting node is full), no_match (no node fits), active (the per-user cap). */
|
|
107
|
+
export interface LaunchWaitingInfo {
|
|
108
|
+
readonly wait: "capacity" | "no_match" | "active"
|
|
109
|
+
readonly reason: string
|
|
110
|
+
/** ms epoch: since when it has waited for this kind of reason. */
|
|
111
|
+
readonly since: number
|
|
112
|
+
}
|
|
113
|
+
|
|
106
114
|
/** A launch as the API reports it. */
|
|
107
115
|
export interface LaunchInfo {
|
|
108
116
|
readonly id: string
|
|
@@ -118,6 +126,12 @@ export interface LaunchInfo {
|
|
|
118
126
|
readonly name?: string
|
|
119
127
|
readonly outcome?: LaunchOutcome
|
|
120
128
|
readonly artifacts?: LaunchArtifacts
|
|
129
|
+
/** Only while queued: why it waits (`reason` is the same text). */
|
|
130
|
+
readonly waiting?: LaunchWaitingInfo
|
|
131
|
+
/** The launcher's label when it launched (an app token's label). */
|
|
132
|
+
readonly byLabel?: string
|
|
133
|
+
/** The app token that launched it was revoked: it still runs and counts toward the cap until stopped. */
|
|
134
|
+
readonly launcherRevoked?: boolean
|
|
121
135
|
}
|
|
122
136
|
|
|
123
137
|
// ---------- checks ----------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-runtime-openshell",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -269,8 +269,11 @@ export type MessageKind = "msg" | "ask" | "reply" | "decline"
|
|
|
269
269
|
/** `sent` for msg/reply/decline; asks are open until answered, declined or expired. */
|
|
270
270
|
export type MessageState = "sent" | "open" | "answered" | "declined" | "expired"
|
|
271
271
|
/** How a message reached its recipient session. */
|
|
272
|
-
/**
|
|
273
|
-
|
|
272
|
+
/**
|
|
273
|
+
* (CON-10) "dropped": it was pending for a session that ended for good (a retired Concierge session); never delivered.
|
|
274
|
+
* "expired": it was pending for 24 h (its recipient stayed offline or busy); never delivered, and its sender was told.
|
|
275
|
+
*/
|
|
276
|
+
export type MessageDelivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped" | "expired"
|
|
274
277
|
|
|
275
278
|
/** A stored agent message. */
|
|
276
279
|
export interface AgentMessage {
|
|
@@ -296,10 +299,21 @@ export interface AgentMessage {
|
|
|
296
299
|
readonly answer?: { readonly id: string; readonly at: number }
|
|
297
300
|
}
|
|
298
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Why a message to a session did not start a turn: the sender asked wake:false, the recipient is a subagent
|
|
304
|
+
* (never woken), the reply chain went past the hop cap (8 hops without a human turn), or the recipient's wake
|
|
305
|
+
* budget (30 per 10 minutes) is spent. The message is still delivered.
|
|
306
|
+
*/
|
|
307
|
+
export type WakeSkip = "wake_false" | "subagent" | "hop_cap" | "budget"
|
|
308
|
+
|
|
299
309
|
/** POST /api/agent/msg and /ask: the stored message and how it was delivered (none: stored only). */
|
|
300
310
|
export interface Sent {
|
|
301
311
|
readonly message: AgentMessage
|
|
302
312
|
readonly delivery?: MessageDelivery
|
|
313
|
+
/** Messages to a session: whether the hub sent it to wake the recipient (absent from hubs before 0.8.1). */
|
|
314
|
+
readonly woke?: boolean
|
|
315
|
+
/** When woke is false: why. */
|
|
316
|
+
readonly wakeSkipped?: WakeSkip
|
|
303
317
|
}
|
|
304
318
|
|
|
305
319
|
/** POST /api/agent/msg or /ask with all: one per session. */
|
|
@@ -577,6 +591,12 @@ export interface LaunchView {
|
|
|
577
591
|
readonly updated: number
|
|
578
592
|
/** Who launched it: `user`, `app:<token id>` or a session ID. */
|
|
579
593
|
readonly by?: string
|
|
594
|
+
/** The launcher's label when it launched (an app token's label). */
|
|
595
|
+
readonly byLabel?: string
|
|
596
|
+
/** The app token that launched it was revoked: it still runs and counts toward the cap until stopped. */
|
|
597
|
+
readonly launcherRevoked?: boolean
|
|
598
|
+
/** Only while queued: why it waits (`reason` is the same text). */
|
|
599
|
+
readonly waiting?: { readonly wait: "capacity" | "no_match" | "active"; readonly reason: string; readonly since: number }
|
|
580
600
|
readonly machineID?: string
|
|
581
601
|
readonly directory?: string
|
|
582
602
|
readonly sessionID?: string
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Why a message did not wake its recipient (Sent.woke / Sent.wakeSkipped, hub 0.8.1), in the words the CLI, `hub mcp`
|
|
2
|
+
// and the OpenCode hub tool show: one place so every client says the same thing.
|
|
3
|
+
import type { Sent, WakeSkip } from "./types.ts"
|
|
4
|
+
|
|
5
|
+
/** One short reason per WakeSkip. */
|
|
6
|
+
export const WAKE_SKIP_TEXT: Readonly<Record<WakeSkip, string>> = {
|
|
7
|
+
wake_false: "sent without a wake",
|
|
8
|
+
subagent: "subagents are never woken",
|
|
9
|
+
hop_cap: "hop limit reached (8 agent messages without a human turn)",
|
|
10
|
+
budget: "wake budget spent (30 per 10 minutes)",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ` — wake skipped: <reason>` when the hub did not wake the recipient and the message waits for a later turn; empty
|
|
15
|
+
* when it woke, went into a running turn (steer) or started one, or the hub did not say (hubs before 0.8.1).
|
|
16
|
+
*/
|
|
17
|
+
export function wakeSkipNote({ delivery, woke, wakeSkipped }: Pick<Sent, "delivery" | "woke" | "wakeSkipped">): string {
|
|
18
|
+
if (woke !== false || !wakeSkipped || delivery === "steer" || delivery === "turn") return ""
|
|
19
|
+
return ` — wake skipped: ${WAKE_SKIP_TEXT[wakeSkipped] ?? wakeSkipped}`
|
|
20
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-spoke-sdk",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -790,6 +790,9 @@ export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
|
|
|
790
790
|
return ["forbidden", "refused"]
|
|
791
791
|
case "rate_limited":
|
|
792
792
|
return ["rate_limited", "refused"]
|
|
793
|
+
// (FX-07) The hub's state did not answer in time: refused, and Activity says the hub was busy.
|
|
794
|
+
case "busy":
|
|
795
|
+
return ["busy", "refused"]
|
|
793
796
|
case "needs_user":
|
|
794
797
|
return ["needs_user", "refused"]
|
|
795
798
|
case "gone":
|
|
@@ -29,7 +29,7 @@ export const AUDIT_PAGE_DEFAULT = 100
|
|
|
29
29
|
export const AUDIT_PAGE_MAX = 500
|
|
30
30
|
|
|
31
31
|
/** The policy's decision: allowed, or why not. */
|
|
32
|
-
export type AuditDecision = "allow" | "needs_user" | "forbidden" | "paused" | "rate_limited"
|
|
32
|
+
export type AuditDecision = "allow" | "needs_user" | "forbidden" | "paused" | "rate_limited" | "busy"
|
|
33
33
|
/** What happened: done, failed, raced (gone), the spoke could not (offline, unsupported), escalated or refused. */
|
|
34
34
|
export type AuditOutcome = "ok" | "failed" | "gone" | "offline" | "unsupported" | "escalated" | "refused"
|
|
35
35
|
|
|
@@ -106,6 +106,8 @@ export interface ActAction {
|
|
|
106
106
|
readonly target: string
|
|
107
107
|
/** The verb's validated input (requestID, formID, answer, text). */
|
|
108
108
|
readonly input: Readonly<Record<string, unknown>>
|
|
109
|
+
/** (FX-05) deny: the permission it would deny, for the console's card. */
|
|
110
|
+
readonly request?: DecisionRequest
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
/** An escalated permission request: the answer replies to it as the user. */
|
|
@@ -118,6 +120,56 @@ export interface PermissionAction {
|
|
|
118
120
|
readonly sessionID: string
|
|
119
121
|
/** The agent asked to approve it always. */
|
|
120
122
|
readonly always: boolean
|
|
123
|
+
/** (FX-05) What it asks, for the console's card (absent when the hub did not know the request's details). */
|
|
124
|
+
readonly request?: DecisionRequest
|
|
125
|
+
/** (FX-05) Why the hub sent it to the user (the evaluation's reason). */
|
|
126
|
+
readonly reason?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* (FX-05) A permission request as a Decision shows it (bounded): its kind, what it applies to and the patterns an
|
|
131
|
+
* "always" would save. What was not stored is recorded, so a shortened command never reads as complete: the totals
|
|
132
|
+
* when items were dropped, each cut item with its full length, and `truncated` when the hub received the request
|
|
133
|
+
* already shortened (src/status.ts permissionRequest). The console renders it as text; the Concierge's Decision reads
|
|
134
|
+
* leave it out (concierge/board-routes.ts conciergeDecisionView).
|
|
135
|
+
*/
|
|
136
|
+
export interface DecisionRequest {
|
|
137
|
+
readonly action: string
|
|
138
|
+
readonly resources: readonly string[]
|
|
139
|
+
readonly save?: readonly string[]
|
|
140
|
+
/** How many resources the request had, when more than were stored. */
|
|
141
|
+
readonly resourcesTotal?: number
|
|
142
|
+
/** How many save patterns it had, when more than were stored. */
|
|
143
|
+
readonly savesTotal?: number
|
|
144
|
+
/** Stored items cut at DECISION_REQUEST_TEXT characters, with their full length. */
|
|
145
|
+
readonly cut?: readonly { readonly field: "resources" | "save"; readonly index: number; readonly length: number }[]
|
|
146
|
+
/** The hub received the request already shortened. */
|
|
147
|
+
readonly truncated?: true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Resources (and save patterns) a Decision keeps of a request, and the characters of each. */
|
|
151
|
+
export const DECISION_REQUEST_ITEMS = 20
|
|
152
|
+
export const DECISION_REQUEST_TEXT = 2_000
|
|
153
|
+
|
|
154
|
+
/** A permission request, bounded for a Decision, with what was not stored. */
|
|
155
|
+
export function decisionRequest(request: { readonly action: string; readonly resources: readonly string[]; readonly save?: readonly string[]; readonly truncated?: true }): DecisionRequest {
|
|
156
|
+
const cut: { field: "resources" | "save"; index: number; length: number }[] = []
|
|
157
|
+
const bound = (field: "resources" | "save", list: readonly string[]) =>
|
|
158
|
+
list.slice(0, DECISION_REQUEST_ITEMS).map((r, index) => {
|
|
159
|
+
if (r.length > DECISION_REQUEST_TEXT) cut.push({ field, index, length: r.length })
|
|
160
|
+
return r.slice(0, DECISION_REQUEST_TEXT)
|
|
161
|
+
})
|
|
162
|
+
const resources = bound("resources", request.resources)
|
|
163
|
+
const save = request.save?.length ? bound("save", request.save) : undefined
|
|
164
|
+
return {
|
|
165
|
+
action: request.action.slice(0, DECISION_REQUEST_TEXT),
|
|
166
|
+
resources,
|
|
167
|
+
...(save ? { save } : {}),
|
|
168
|
+
...(request.resources.length > resources.length ? { resourcesTotal: request.resources.length } : {}),
|
|
169
|
+
...(save && request.save!.length > save.length ? { savesTotal: request.save!.length } : {}),
|
|
170
|
+
...(cut.length ? { cut } : {}),
|
|
171
|
+
...(request.truncated ? { truncated: true as const } : {}),
|
|
172
|
+
}
|
|
121
173
|
}
|
|
122
174
|
|
|
123
175
|
/** A grant an agent requested (grant-requests.ts): approving creates it as the user. */
|
|
@@ -457,11 +509,15 @@ export class Decisions {
|
|
|
457
509
|
|
|
458
510
|
// ---- internals ----------------------------------------------------------------------------------------
|
|
459
511
|
|
|
460
|
-
/**
|
|
512
|
+
/**
|
|
513
|
+
* Whether the session is one of the user's Concierge sessions; a failed read is no label (never fails the ask).
|
|
514
|
+
* (FX-07) A read that timed out (`busy`) refuses the ask instead: the label could not be checked (fail closed).
|
|
515
|
+
*/
|
|
461
516
|
private async isConcierge(userID: string, sessionID: string): Promise<boolean> {
|
|
462
517
|
try {
|
|
463
518
|
return (await this.deps.concierge?.(userID, sessionID)) === true
|
|
464
|
-
} catch {
|
|
519
|
+
} catch (cause) {
|
|
520
|
+
if (cause instanceof AgentError && cause.code === "busy") throw cause
|
|
465
521
|
return false
|
|
466
522
|
}
|
|
467
523
|
}
|