@huynle/hub 0.7.2 → 0.7.4
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 +29 -0
- package/README.md +5 -5
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +7 -7
- package/packages/cli/package.json +1 -1
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- 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/spoke-sdk/package.json +1 -1
- package/src/central/http.ts +2 -1
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +7 -0
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -0
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-DWnMhXk9.js +0 -20
- package/web/dist/assets/index-DmUXEnw9.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.4
|
|
6
|
+
|
|
7
|
+
Release downloads move to a public repository. The source repository is private now, so the standalone `hub`
|
|
8
|
+
binaries, `hubctl`, its signed manifest and the checksums are published to
|
|
9
|
+
[`huynle/hub-releases`](https://github.com/huynle/hub-releases) (the release workflow mirrors every release there,
|
|
10
|
+
byte for byte, so the signed manifest and checksums stay valid). The image `ghcr.io/huynle/hub` and the npm package
|
|
11
|
+
`@huynle/hub` are unchanged.
|
|
12
|
+
|
|
13
|
+
- **Install**: the README, the Ansible role README and the web shell hint use `huynle/hub-releases`; pass
|
|
14
|
+
`--release-url https://github.com/huynle/hub-releases/releases/download/v<version>` to `hubctl setup ansible`.
|
|
15
|
+
- **Plugin rename notice** no longer suggests installing from `github:huynle/hub`.
|
|
16
|
+
|
|
17
|
+
## 0.7.3
|
|
18
|
+
|
|
19
|
+
Session errors are visible: a quota, auth or rate-limit error in an OpenCode session now shows up in the hub.
|
|
20
|
+
|
|
21
|
+
- **Retries with OpenCode 2.2**: OpenCode 2.2 no longer sends `session.status`; the plugin translates
|
|
22
|
+
`session.retry.scheduled` into a `session.status` retry with `next` (time of the next try), `errorType` and
|
|
23
|
+
`errorStatus`, so older hubs keep working. `session.execution.failed` errors go out as `{message, type, status}` (no
|
|
24
|
+
provider response body). A pending retry and the last failure are re-sent after a reconnect.
|
|
25
|
+
- **Console**: an alert banner in the session header ("Quota exceeded (HTTP 429): …"), error and retry rows in the
|
|
26
|
+
transcript (with a live countdown), a "!" badge in session lists; a failed session is listed under Needs you until
|
|
27
|
+
it runs again or is archived.
|
|
28
|
+
- **Where a session runs**: the session header names machine · project › folder · git branch (a line under the
|
|
29
|
+
header on phones).
|
|
30
|
+
- **Notifications**: new `providerError` ("Model provider error", on by default) for quota, billing, auth, no-route
|
|
31
|
+
and unknown provider errors in any session of the tree, once per session per run. Devices with it off still get
|
|
32
|
+
`failed`. A root's and a subagent's notifications no longer replace each other on the phone.
|
|
33
|
+
|
|
5
34
|
## 0.7.2
|
|
6
35
|
|
|
7
36
|
First release with signed standalone `hubctl` binaries (`hubctl-<target>`, `hubctl-manifest.json` signed by `hub-release-1`, `HUBCTL_SHA256SUMS`) and pinned OpenCode 2.2.0 for `hubctl setup`.
|
package/README.md
CHANGED
|
@@ -145,8 +145,8 @@ shell, and the hub never gets a way to run commands here.
|
|
|
145
145
|
# 1. Download hubctl for your platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64) and the signed manifest
|
|
146
146
|
# from the release, then install the binary yourself (the first apply installs the release's copy as
|
|
147
147
|
# ~/.local/bin/hubctl, with `hub` linked to it):
|
|
148
|
-
curl -fsSLo hubctl https://github.com/huynle/hub/releases/download/v<version>/hubctl-darwin-arm64 && chmod +x hubctl
|
|
149
|
-
curl -fsSLO https://github.com/huynle/hub/releases/download/v<version>/hubctl-manifest.json
|
|
148
|
+
curl -fsSLo hubctl https://github.com/huynle/hub-releases/releases/download/v<version>/hubctl-darwin-arm64 && chmod +x hubctl
|
|
149
|
+
curl -fsSLO https://github.com/huynle/hub-releases/releases/download/v<version>/hubctl-manifest.json
|
|
150
150
|
|
|
151
151
|
# 2. Enroll this machine (device code: approve it on the hub; Ctrl-C cancels). One identity for every spoke here.
|
|
152
152
|
./hubctl machine enroll --server https://hub.example.com
|
|
@@ -209,7 +209,7 @@ The hub serves the web app, pairs devices, enrolls machines, and does push and (
|
|
|
209
209
|
| Way | For | How |
|
|
210
210
|
|---|---|---|
|
|
211
211
|
| **`/hub` → On this computer** | Local use; nothing to install (plugin from npm) | started by the plugin with OpenCode's own runtime (`BUN_BE_BUN=1 opencode bin/hub.ts`), or `bun` when installed |
|
|
212
|
-
| **Standalone binary** | Any server or laptop, no runtime needed | `hub-<os>-<arch>` from the [releases](https://github.com/huynle/hub/releases) |
|
|
212
|
+
| **Standalone binary** | Any server or laptop, no runtime needed | `hub-<os>-<arch>` from the [releases](https://github.com/huynle/hub-releases/releases) (the source repository is private; releases are mirrored to the public `huynle/hub-releases`) |
|
|
213
213
|
| **Docker** | Servers | `ghcr.io/huynle/hub` (linux/amd64, linux/arm64) |
|
|
214
214
|
| **bunx / from source** | Bun users, development | `bunx --package @huynle/hub hub-server` · `bun bin/hub.ts` |
|
|
215
215
|
| **Kubernetes (cluster mode)** | Several pods on Postgres + Valkey, rolling updates | [docs/cluster.md](docs/cluster.md), manifests in [`deploy/k8s/`](deploy/k8s/) |
|
|
@@ -221,8 +221,8 @@ answers `--help` and `--version`. While no device is paired, the hub logs a pair
|
|
|
221
221
|
`hub-<darwin|linux>-<arm64|x64>` and `hub-windows-x64.exe`, plus `SHA256SUMS`.
|
|
222
222
|
|
|
223
223
|
```sh
|
|
224
|
-
curl -LO https://github.com/huynle/hub/releases/latest/download/hub-linux-x64
|
|
225
|
-
curl -LO https://github.com/huynle/hub/releases/latest/download/SHA256SUMS
|
|
224
|
+
curl -LO https://github.com/huynle/hub-releases/releases/latest/download/hub-linux-x64
|
|
225
|
+
curl -LO https://github.com/huynle/hub-releases/releases/latest/download/SHA256SUMS
|
|
226
226
|
sha256sum --check --ignore-missing SHA256SUMS && chmod +x hub-linux-x64
|
|
227
227
|
HUB_PUBLIC_URL=https://hub.example.com ./hub-linux-x64
|
|
228
228
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Huy Le",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-claude-code",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -7,7 +7,7 @@ import { createInterface } from "readline";
|
|
|
7
7
|
// packages/cli/package.json
|
|
8
8
|
var package_default = {
|
|
9
9
|
name: "@huynle/hub-cli",
|
|
10
|
-
version: "0.7.
|
|
10
|
+
version: "0.7.4",
|
|
11
11
|
private: true,
|
|
12
12
|
description: "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
|
|
13
13
|
license: "MIT",
|
|
@@ -3292,7 +3292,7 @@ var FORWARDED_EVENTS = new Set([
|
|
|
3292
3292
|
"pty.deleted",
|
|
3293
3293
|
"session.seed"
|
|
3294
3294
|
]);
|
|
3295
|
-
var COMPACTED_EVENTS = new Set(["session.tool.progress"]);
|
|
3295
|
+
var COMPACTED_EVENTS = new Set(["session.tool.progress", "session.retry.scheduled"]);
|
|
3296
3296
|
var HARNESS_OPENCODE = "opencode";
|
|
3297
3297
|
var EVIDENCE_LEVELS = ["native", "hooks", "rpc", "screen"];
|
|
3298
3298
|
var IMPLIED_OPENCODE_FEATURES = [
|
|
@@ -5919,7 +5919,7 @@ import { arch as osArch, platform as osPlatform } from "node:os";
|
|
|
5919
5919
|
// packages/node/package.json
|
|
5920
5920
|
var package_default2 = {
|
|
5921
5921
|
name: "@huynle/hub-node",
|
|
5922
|
-
version: "0.7.
|
|
5922
|
+
version: "0.7.4",
|
|
5923
5923
|
private: true,
|
|
5924
5924
|
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).",
|
|
5925
5925
|
license: "MIT",
|
|
@@ -7010,7 +7010,7 @@ import path10 from "node:path";
|
|
|
7010
7010
|
// packages/runtime-openshell/package.json
|
|
7011
7011
|
var package_default3 = {
|
|
7012
7012
|
name: "@huynle/hub-runtime-openshell",
|
|
7013
|
-
version: "0.7.
|
|
7013
|
+
version: "0.7.4",
|
|
7014
7014
|
private: true,
|
|
7015
7015
|
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).",
|
|
7016
7016
|
license: "MIT",
|
|
@@ -10395,7 +10395,7 @@ import path18 from "node:path";
|
|
|
10395
10395
|
// packages/claude-code/package.json
|
|
10396
10396
|
var package_default4 = {
|
|
10397
10397
|
name: "@huynle/hub-claude-code",
|
|
10398
|
-
version: "0.7.
|
|
10398
|
+
version: "0.7.4",
|
|
10399
10399
|
private: true,
|
|
10400
10400
|
description: "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
|
|
10401
10401
|
license: "MIT",
|
|
@@ -10684,7 +10684,7 @@ import path21 from "node:path";
|
|
|
10684
10684
|
// packages/preset/package.json
|
|
10685
10685
|
var package_default5 = {
|
|
10686
10686
|
name: "@huynle/hub-preset",
|
|
10687
|
-
version: "0.7.
|
|
10687
|
+
version: "0.7.4",
|
|
10688
10688
|
private: true,
|
|
10689
10689
|
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).",
|
|
10690
10690
|
license: "MIT",
|
|
@@ -11185,7 +11185,7 @@ import { openSync as openSync4, closeSync as closeSync4, readFileSync as readFil
|
|
|
11185
11185
|
// packages/codex/package.json
|
|
11186
11186
|
var package_default6 = {
|
|
11187
11187
|
name: "@huynle/hub-codex",
|
|
11188
|
-
version: "0.7.
|
|
11188
|
+
version: "0.7.4",
|
|
11189
11189
|
private: true,
|
|
11190
11190
|
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).",
|
|
11191
11191
|
license: "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-codex",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-node",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-preset",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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",
|
|
@@ -74,10 +74,10 @@ export interface HarnessEventData {
|
|
|
74
74
|
/** A turn started (the session is busy). `turnID`: the harness's turn, when it has one. */
|
|
75
75
|
"session.execution.started": Turn
|
|
76
76
|
"session.execution.succeeded": Turn
|
|
77
|
-
"session.execution.failed": Turn & { readonly error?: { readonly message: string } }
|
|
77
|
+
"session.execution.failed": Turn & { readonly error?: { readonly message: string; readonly type?: string; readonly status?: number } }
|
|
78
78
|
/** `inferred`: the harness reported no interruption; the spoke found it later (see the header). */
|
|
79
79
|
"session.execution.interrupted": Turn & { readonly inferred?: true }
|
|
80
|
-
"session.status": { readonly sessionID: string; readonly status: { readonly type: "idle" | "busy" } | { readonly type: "retry"; readonly attempt?: number; readonly message?: string } }
|
|
80
|
+
"session.status": { readonly sessionID: string; readonly status: { readonly type: "idle" | "busy" } | { readonly type: "retry"; readonly attempt?: number; readonly message?: string; readonly next?: number; readonly errorType?: string; readonly errorStatus?: number } }
|
|
81
81
|
/** The turn's reply (e.g. Claude Code's Stop last_assistant_message); `ordinal` counts replies in the message. */
|
|
82
82
|
"session.text.ended": { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number; readonly text: string }
|
|
83
83
|
/** A prompt entered the session: typed by the user, or a message the hub delivered. */
|
|
@@ -91,7 +91,7 @@ export interface TimelinePage {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/** Timeline row kinds. "message": a hub message (src/spoke/timeline.ts has the others). */
|
|
94
|
-
export const TIMELINE_KINDS = ["user", "assistant", "reasoning", "tool", "message"] as const
|
|
94
|
+
export const TIMELINE_KINDS = ["user", "assistant", "reasoning", "tool", "message", "error", "retry"] as const
|
|
95
95
|
export type TimelineKind = (typeof TIMELINE_KINDS)[number]
|
|
96
96
|
|
|
97
97
|
/** A message row's direction from the session's point of view: delivered to it (in) or sent by it (out). */
|
|
@@ -230,15 +230,21 @@ export interface InboxDeliveredData {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
/**
|
|
233
|
-
* OpenCode events the spoke forwards only in another (compact) form: a subagent tool's
|
|
234
|
-
* child session, goes out as a `session.tool.called` with `childSessionID
|
|
233
|
+
* OpenCode events the spoke forwards only in another (compact) form, as a type every hub accepts: a subagent tool's
|
|
234
|
+
* progress, which names the child session, goes out as a `session.tool.called` with `childSessionID`; a scheduled
|
|
235
|
+
* retry (OpenCode 2.2 publishes no session.status) goes out as a `session.status` of type retry. Never add these to
|
|
236
|
+
* FORWARDED_EVENTS: older hubs close the connection on an event type they do not know.
|
|
235
237
|
*/
|
|
236
|
-
export const COMPACTED_EVENTS: ReadonlySet<string> = new Set(["session.tool.progress"])
|
|
238
|
+
export const COMPACTED_EVENTS: ReadonlySet<string> = new Set(["session.tool.progress", "session.retry.scheduled"])
|
|
237
239
|
|
|
238
240
|
/** Longest job command forwarded. */
|
|
239
241
|
export const JOB_COMMAND_MAX = 200
|
|
240
242
|
/** Longest retry reason forwarded. */
|
|
241
243
|
export const RETRY_MESSAGE_MAX = 200
|
|
244
|
+
/** Longest run error message forwarded (session.execution.failed; as HARNESS_ERROR_MAX). */
|
|
245
|
+
export const ERROR_MESSAGE_MAX = 1000
|
|
246
|
+
/** Longest error type forwarded (e.g. "provider.quota"). */
|
|
247
|
+
export const ERROR_TYPE_MAX = 64
|
|
242
248
|
/** Most running jobs one seed event carries. */
|
|
243
249
|
export const SEED_JOBS_MAX = 20
|
|
244
250
|
|
|
@@ -258,7 +264,27 @@ export interface JobInfo {
|
|
|
258
264
|
}
|
|
259
265
|
|
|
260
266
|
/** OpenCode's session status, compact. */
|
|
261
|
-
export type CompactSessionStatus =
|
|
267
|
+
export type CompactSessionStatus =
|
|
268
|
+
| { readonly type: "idle" | "busy" }
|
|
269
|
+
| {
|
|
270
|
+
readonly type: "retry"
|
|
271
|
+
readonly attempt?: number
|
|
272
|
+
/** Why it retries (the error's message, ≤ RETRY_MESSAGE_MAX). */
|
|
273
|
+
readonly message?: string
|
|
274
|
+
/** When the next attempt runs (epoch ms). */
|
|
275
|
+
readonly next?: number
|
|
276
|
+
/** The error's type, e.g. "provider.rate-limit" (≤ ERROR_TYPE_MAX). */
|
|
277
|
+
readonly errorType?: string
|
|
278
|
+
/** The error's HTTP status (100–599). */
|
|
279
|
+
readonly errorStatus?: number
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A run's error as forwarded (session.execution.failed, and a seed's last failure): no response body. */
|
|
283
|
+
export interface CompactRunError {
|
|
284
|
+
readonly message: string
|
|
285
|
+
readonly type?: string
|
|
286
|
+
readonly status?: number
|
|
287
|
+
}
|
|
262
288
|
|
|
263
289
|
// ---------- spoke → server ----------
|
|
264
290
|
|
|
@@ -1097,6 +1123,7 @@ export function summarizeToolInput(input: unknown): string {
|
|
|
1097
1123
|
export function compactActivity(event: ForwardedEvent, sessionOf?: (jobID: string) => string | undefined, now: number = Date.now()): ForwardedEvent | undefined {
|
|
1098
1124
|
const d = event.data
|
|
1099
1125
|
if (JOB_EVENTS.has(event.type) || event.type === "session.status") return compactJob(event, sessionOf, now)
|
|
1126
|
+
if (event.type === "session.retry.scheduled") return compactRetry(d)
|
|
1100
1127
|
const base = { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID, id: d.id }
|
|
1101
1128
|
switch (event.type) {
|
|
1102
1129
|
case "session.tool.input.started":
|
|
@@ -1115,6 +1142,11 @@ export function compactActivity(event: ForwardedEvent, sessionOf?: (jobID: strin
|
|
|
1115
1142
|
const message = typeof (d.error as any)?.message === "string" ? String((d.error as any).message).slice(0, ACTIVITY_SUMMARY_MAX) : "failed"
|
|
1116
1143
|
return { type: event.type, data: { ...base, error: message } }
|
|
1117
1144
|
}
|
|
1145
|
+
case "session.execution.failed":
|
|
1146
|
+
return {
|
|
1147
|
+
type: event.type,
|
|
1148
|
+
data: { sessionID: d.sessionID, ...(typeof d.turnID === "string" && d.turnID ? { turnID: d.turnID } : {}), error: compactRunError(d.error) ?? { message: "failed" } },
|
|
1149
|
+
}
|
|
1118
1150
|
case "session.reasoning.started":
|
|
1119
1151
|
case "session.reasoning.ended":
|
|
1120
1152
|
return { type: event.type, data: { sessionID: d.sessionID, assistantMessageID: d.assistantMessageID } }
|
|
@@ -1146,7 +1178,10 @@ function clipText(value: unknown, max: number): string | undefined {
|
|
|
1146
1178
|
|
|
1147
1179
|
const finite = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
|
|
1148
1180
|
|
|
1149
|
-
|
|
1181
|
+
const errorType = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 && v.length <= ERROR_TYPE_MAX ? v : undefined)
|
|
1182
|
+
const httpStatus = (v: unknown): number | undefined => (Number.isInteger(v) && (v as number) >= 100 && (v as number) <= 599 ? (v as number) : undefined)
|
|
1183
|
+
|
|
1184
|
+
/** OpenCode's SessionStatus reduced to its type and (retry) attempt, short reason, next try and error kind. */
|
|
1150
1185
|
export function compactStatus(raw: unknown): CompactSessionStatus | undefined {
|
|
1151
1186
|
const status = raw as Record<string, unknown> | null
|
|
1152
1187
|
if (!status || typeof status !== "object") return undefined
|
|
@@ -1154,7 +1189,35 @@ export function compactStatus(raw: unknown): CompactSessionStatus | undefined {
|
|
|
1154
1189
|
if (status.type !== "retry") return undefined
|
|
1155
1190
|
const attempt = finite(status.attempt)
|
|
1156
1191
|
const message = clipText(status.message, RETRY_MESSAGE_MAX)
|
|
1157
|
-
|
|
1192
|
+
const next = finite(status.next)
|
|
1193
|
+
const type = errorType(status.errorType)
|
|
1194
|
+
const code = httpStatus(status.errorStatus)
|
|
1195
|
+
return {
|
|
1196
|
+
type: "retry",
|
|
1197
|
+
...(attempt !== undefined ? { attempt } : {}),
|
|
1198
|
+
...(message ? { message } : {}),
|
|
1199
|
+
...(next !== undefined ? { next } : {}),
|
|
1200
|
+
...(type ? { errorType: type } : {}),
|
|
1201
|
+
...(code !== undefined ? { errorStatus: code } : {}),
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** An OpenCode run error ({type, message, status?, response?, retryAfterMs?}) as its message, type and status only. */
|
|
1206
|
+
export function compactRunError(raw: unknown): CompactRunError | undefined {
|
|
1207
|
+
const error = raw as Record<string, unknown> | null
|
|
1208
|
+
if (!error || typeof error !== "object") return undefined
|
|
1209
|
+
const message = typeof error.message === "string" && error.message.trim() ? error.message.trim().slice(0, ERROR_MESSAGE_MAX) : "failed"
|
|
1210
|
+
const type = errorType(error.type)
|
|
1211
|
+
const status = httpStatus(error.status)
|
|
1212
|
+
return { message, ...(type ? { type } : {}), ...(status !== undefined ? { status } : {}) }
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** OpenCode 2.2's session.retry.scheduled as the session.status retry every hub accepts. */
|
|
1216
|
+
function compactRetry(d: Record<string, unknown>): ForwardedEvent | undefined {
|
|
1217
|
+
if (typeof d.sessionID !== "string" || !d.sessionID) return undefined
|
|
1218
|
+
const error = (d.error && typeof d.error === "object" ? d.error : {}) as Record<string, unknown>
|
|
1219
|
+
const status = compactStatus({ type: "retry", attempt: d.attempt, message: error.message, next: d.at, errorType: error.type, errorStatus: error.status })
|
|
1220
|
+
return { type: "session.status", data: { sessionID: d.sessionID, status } }
|
|
1158
1221
|
}
|
|
1159
1222
|
|
|
1160
1223
|
/** A ShellInfo or Pty (OpenCode) as a JobInfo. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-runtime-openshell",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-spoke-sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
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",
|
package/src/central/http.ts
CHANGED
|
@@ -251,7 +251,7 @@ const WEB_NOT_BUILT = [
|
|
|
251
251
|
"This hub has no built web app (web/dist).",
|
|
252
252
|
"",
|
|
253
253
|
"To run a hub, install it from npm (`opencode plugin add @huynle/hub`, or `bunx --package @huynle/hub hub-server`)",
|
|
254
|
-
"or use a release binary (https://github.com/huynle/hub/releases), which ship the web app.",
|
|
254
|
+
"or use a release binary (https://github.com/huynle/hub-releases/releases), which ship the web app.",
|
|
255
255
|
"In a source checkout, run `npm install && npm run build:web` first.",
|
|
256
256
|
].join("\n")
|
|
257
257
|
/** Preview text for POST /api/settings/speech/test. */
|
|
@@ -541,6 +541,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
541
541
|
...(alert.requestID ? { requestID: alert.requestID } : {}),
|
|
542
542
|
...(alert.subject ? { subject: alert.subject } : {}),
|
|
543
543
|
...(alert.key ? { key: alert.key } : {}),
|
|
544
|
+
...(alert.unlessPref ? { unlessPref: alert.unlessPref } : {}),
|
|
544
545
|
}
|
|
545
546
|
notifier.notify(alert.kind, alert.sessionID, alert.title, alert.text, alert.owner, extra).catch((cause) => {
|
|
546
547
|
console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
|
package/src/central/push.ts
CHANGED
|
@@ -32,6 +32,7 @@ export const NOTIFICATION_LABELS: Readonly<Record<NotificationKind, string>> = O
|
|
|
32
32
|
activity: "Activity",
|
|
33
33
|
decision: "Decision",
|
|
34
34
|
message: "Message",
|
|
35
|
+
providerError: "Model provider error",
|
|
35
36
|
})
|
|
36
37
|
|
|
37
38
|
/** True for a known notification kind (API input). */
|
|
@@ -47,6 +48,8 @@ export interface PushExtra {
|
|
|
47
48
|
readonly key?: string
|
|
48
49
|
/** decision: the Decision, so a tap opens its card. */
|
|
49
50
|
readonly decisionID?: string
|
|
51
|
+
/** Skip devices that have this kind on (they were notified through it instead). Not sent. */
|
|
52
|
+
readonly unlessPref?: NotificationKind
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
export interface PushPayload {
|
|
@@ -58,6 +61,8 @@ export interface PushPayload {
|
|
|
58
61
|
readonly decisionID?: string
|
|
59
62
|
/** A test notification shaped like `kind`. */
|
|
60
63
|
readonly test?: true
|
|
64
|
+
/** Per-session kinds (providerError, subagentFailed): the session it is about, so the service worker tags each apart. */
|
|
65
|
+
readonly tagKey?: string
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
const minutes = (clock: string): number => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5))
|
|
@@ -188,6 +193,8 @@ function kindTitle(kind: NotificationKind, subject: string | undefined): string
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
const OPEN = "Tap to open the session."
|
|
196
|
+
/** A session ID: the only key a payload may carry (job and tool IDs stay on the hub). */
|
|
197
|
+
const SESSION_ID = /^ses_[A-Za-z0-9]{1,64}$/
|
|
191
198
|
|
|
192
199
|
export function pushPayload(kind: NotificationKind, sessionID: string, sessionTitle: string, text?: string, extra: PushExtra = {}): PushPayload {
|
|
193
200
|
const name = shorten(sessionTitle)
|
|
@@ -213,6 +220,9 @@ export function pushPayload(kind: NotificationKind, sessionID: string, sessionTi
|
|
|
213
220
|
return { kind, sessionID, title, body: detail || "Nothing is running or waiting. Tap to open." }
|
|
214
221
|
case "activity":
|
|
215
222
|
return { kind, sessionID, title, body: detail || "New activity." }
|
|
223
|
+
case "providerError":
|
|
224
|
+
case "subagentFailed":
|
|
225
|
+
return { kind, sessionID, title, body: detail || OPEN, ...(extra.key && SESSION_ID.test(extra.key) ? { tagKey: extra.key } : {}) }
|
|
216
226
|
default:
|
|
217
227
|
return { kind, sessionID, title, body: detail || OPEN }
|
|
218
228
|
}
|
|
@@ -229,6 +239,7 @@ export function sendOptionsFor(kind: NotificationKind | "test"): { TTL: number;
|
|
|
229
239
|
case "failed":
|
|
230
240
|
case "decision":
|
|
231
241
|
case "message":
|
|
242
|
+
case "providerError":
|
|
232
243
|
return { TTL: URGENT_TTL_SECONDS, urgency: "high" }
|
|
233
244
|
case "done":
|
|
234
245
|
case "stopped":
|
|
@@ -385,7 +396,9 @@ export class Push {
|
|
|
385
396
|
const targets = this.store.pushSubscriptions().filter((s) => {
|
|
386
397
|
const owner = owners.get(s.deviceID)
|
|
387
398
|
if (ownerID !== undefined && owner !== ownerID) return false
|
|
388
|
-
|
|
399
|
+
const prefs = this.store.pushPrefs(s.deviceID)
|
|
400
|
+
if (!prefs[precise]) return false
|
|
401
|
+
if (extra?.unlessPref && prefs[extra.unlessPref]) return false
|
|
389
402
|
if (precise === "handoff") return true
|
|
390
403
|
const quiet = this.store.quietHours(s.deviceID)
|
|
391
404
|
return !isMuted(owner) && !(quiet && inQuietHours(quiet, at))
|
package/src/central/registry.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// holds the connection; remote state is never re-exported.
|
|
13
13
|
import path from "node:path"
|
|
14
14
|
import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
|
|
15
|
-
import { StatusBoard, permissionRequest, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
15
|
+
import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
|
|
16
16
|
import { sanitizeLabel } from "./agent/envelope"
|
|
17
17
|
import { DoneWatch } from "./done"
|
|
18
18
|
import { SentenceStream } from "./say-stream"
|
|
@@ -150,6 +150,8 @@ export interface Alert {
|
|
|
150
150
|
/** Tenant (user ID) that owns the session; push goes to this user's devices only. */
|
|
151
151
|
readonly owner?: string
|
|
152
152
|
readonly step?: ActivityStep
|
|
153
|
+
/** Skip devices that have this kind on (they were told through it instead). */
|
|
154
|
+
readonly unlessPref?: NotificationKind
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
/** Notifications that reach a session even while some device shows it. */
|
|
@@ -407,6 +409,8 @@ export class Registry {
|
|
|
407
409
|
private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
|
|
408
410
|
/** Form IDs already alerted (forms may be forwarded by several processes). */
|
|
409
411
|
private readonly alertedForms = new Set<string>()
|
|
412
|
+
/** Sessions whose current run raised a providerError notification (once per run; cleared when a run starts). */
|
|
413
|
+
private readonly providerAlerted = new Set<string>()
|
|
410
414
|
/** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
|
|
411
415
|
private readonly conversations = new Map<string, string | undefined>()
|
|
412
416
|
/** `messageID ordinal` of texts already sent as say events (several processes forward the same text). */
|
|
@@ -1433,15 +1437,27 @@ export class Registry {
|
|
|
1433
1437
|
if (!rootID) return
|
|
1434
1438
|
const root = rootID === sessionID
|
|
1435
1439
|
const wasRunning = before?.state === "working" || before?.state === "blocked"
|
|
1436
|
-
if (type === "session.execution.started")
|
|
1440
|
+
if (type === "session.execution.started") {
|
|
1441
|
+
this.providerAlerted.delete(sessionID)
|
|
1442
|
+
return this.doneWatch.started(rootID, root)
|
|
1443
|
+
}
|
|
1437
1444
|
if (type === "session.text.delta" || type === "session.reasoning.delta") return
|
|
1438
1445
|
const ended = type === "session.execution.succeeded" ? "succeeded" : type === "session.execution.failed" ? "failed" : type === "session.execution.interrupted" ? "interrupted" : undefined
|
|
1439
1446
|
const reason = (data.error as { message?: unknown } | undefined)?.message
|
|
1440
1447
|
const error = typeof reason === "string" && reason.trim() ? reason : undefined
|
|
1448
|
+
const errorType = (data.error as { type?: unknown } | undefined)?.type
|
|
1449
|
+
// A provider error the user must act on replaces the root's "failed" (raised once per run).
|
|
1450
|
+
const providerFailure = ended === "failed" && typeof errorType === "string" && providerFailureNeedsUser(errorType)
|
|
1451
|
+
if (providerFailure) this.providerError(rootID, sessionID, error ?? errorTypeLabel(errorType) ?? "Model provider error")
|
|
1452
|
+
if (type === "session.status") {
|
|
1453
|
+
const retry = this.status(rootID)?.find((s) => s.id === sessionID)?.retry
|
|
1454
|
+
if (retry && providerRetryNeedsUser(retry.errorType)) this.providerError(rootID, sessionID, retry.message ?? errorTypeLabel(retry.errorType)!)
|
|
1455
|
+
}
|
|
1441
1456
|
if (ended && root) {
|
|
1442
1457
|
this.doneWatch.rootEnded(rootID, ended, wasRunning)
|
|
1443
1458
|
if (ended === "succeeded" && wasRunning && !this.active.has(rootID)) this.alert("turn", rootID)
|
|
1444
|
-
|
|
1459
|
+
// Devices with providerError on got that instead; the rest still hear about the failure.
|
|
1460
|
+
if (ended === "failed") this.alert("failed", rootID, { ...(error ? { text: error } : {}), ...(providerFailure ? { unlessPref: "providerError" as const } : {}) })
|
|
1445
1461
|
const stopped = ended === "interrupted" ? STOP_REASONS[String(data.reason)] : undefined
|
|
1446
1462
|
if (stopped) this.alert("stopped", rootID, { text: stopped })
|
|
1447
1463
|
return
|
|
@@ -1456,7 +1472,10 @@ export class Registry {
|
|
|
1456
1472
|
}
|
|
1457
1473
|
if (type === "session.status" && root && !before?.retry) {
|
|
1458
1474
|
const retry = this.status(rootID)?.find((s) => s.id === rootID)?.retry
|
|
1459
|
-
if (retry)
|
|
1475
|
+
if (retry) {
|
|
1476
|
+
const why = [retry.attempt !== undefined ? `Attempt ${retry.attempt}` : "", errorTypeLabel(retry.errorType) ?? ""].filter(Boolean).join(" · ")
|
|
1477
|
+
this.alert("retry", rootID, { text: [why, retry.message ?? ""].filter(Boolean).join(": ") || "Retrying" })
|
|
1478
|
+
}
|
|
1460
1479
|
return
|
|
1461
1480
|
}
|
|
1462
1481
|
if (type === "shell.exited" || type === "session.shell.ended" || type === "pty.exited") {
|
|
@@ -1469,6 +1488,15 @@ export class Registry {
|
|
|
1469
1488
|
}
|
|
1470
1489
|
}
|
|
1471
1490
|
|
|
1491
|
+
/** A model provider error of `sessionID` (root or subagent) needs the user: "<session title>: <message>", once per run. */
|
|
1492
|
+
private providerError(rootID: string, sessionID: string, message: string): void {
|
|
1493
|
+
if (this.providerAlerted.has(sessionID)) return
|
|
1494
|
+
this.providerAlerted.add(sessionID)
|
|
1495
|
+
if (this.providerAlerted.size > 1000) this.providerAlerted.delete(this.providerAlerted.values().next().value!)
|
|
1496
|
+
const title = this.status(rootID)?.find((s) => s.id === sessionID)?.title?.trim() || "Session"
|
|
1497
|
+
this.alert("providerError", rootID, { text: `${title}: ${message}`, key: sessionID })
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1472
1500
|
/** Fires when a root's tree stayed settled after its run (DoneWatch). */
|
|
1473
1501
|
private settledTree(rootID: string): boolean {
|
|
1474
1502
|
const board = this.boards.get(rootID)
|
|
@@ -1938,7 +1966,7 @@ export class Registry {
|
|
|
1938
1966
|
* Raises a notification for the root of `sessionID`'s tree. Only hand-offs, questions and permissions reach a
|
|
1939
1967
|
* session some device shows (spoken replies check that themselves: the watching device speaks them).
|
|
1940
1968
|
*/
|
|
1941
|
-
private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string } = {}): void {
|
|
1969
|
+
private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): void {
|
|
1942
1970
|
const entry = [...this.boards].find(([, board]) => board.get(sessionID))
|
|
1943
1971
|
if (!entry) return
|
|
1944
1972
|
const [rootID, board] = entry
|
|
@@ -1951,6 +1979,7 @@ export class Registry {
|
|
|
1951
1979
|
...(fields.text !== undefined ? { text: fields.text } : {}),
|
|
1952
1980
|
...(fields.requestID !== undefined ? { requestID: fields.requestID } : {}),
|
|
1953
1981
|
...(fields.key !== undefined ? { key: fields.key } : {}),
|
|
1982
|
+
...(fields.unlessPref !== undefined ? { unlessPref: fields.unlessPref } : {}),
|
|
1954
1983
|
}
|
|
1955
1984
|
const owner = this.sessionTenant(rootID)
|
|
1956
1985
|
for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
|
package/src/central/store.ts
CHANGED
|
@@ -121,6 +121,11 @@ export interface PushPrefs {
|
|
|
121
121
|
decision: boolean
|
|
122
122
|
/** Agent messaging needs you: a message to @me, an ask left unanswered after every re-wake, or a message loop. */
|
|
123
123
|
message: boolean
|
|
124
|
+
/**
|
|
125
|
+
* A model provider error needs you: a quota, free-tier, budget, auth or no-route failure of any session in the tree
|
|
126
|
+
* (once per run), or a retry for a quota, free-tier or auth error. Replaces the root's "failed" for that failure.
|
|
127
|
+
*/
|
|
128
|
+
providerError: boolean
|
|
124
129
|
}
|
|
125
130
|
|
|
126
131
|
export const PUSH_PREF_KEYS = [
|
|
@@ -141,6 +146,7 @@ export const PUSH_PREF_KEYS = [
|
|
|
141
146
|
"activity",
|
|
142
147
|
"decision",
|
|
143
148
|
"message",
|
|
149
|
+
"providerError",
|
|
144
150
|
] as const satisfies readonly (keyof PushPrefs)[]
|
|
145
151
|
|
|
146
152
|
/** Defaults: anything that needs you, all done, failed, hand-offs and spoken replies; the finer kinds are opt-in. */
|
|
@@ -163,6 +169,7 @@ export const DEFAULT_PUSH_PREFS: Readonly<PushPrefs> = Object.freeze({
|
|
|
163
169
|
activity: false,
|
|
164
170
|
decision: true,
|
|
165
171
|
message: true,
|
|
172
|
+
providerError: true,
|
|
166
173
|
})
|
|
167
174
|
|
|
168
175
|
/** Default and allowed range of a device's activity update interval, in minutes. */
|
package/src/spoke/spoke.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type DeliverRequest,
|
|
16
16
|
type DeliverResult,
|
|
17
17
|
type DeliveryMode,
|
|
18
|
+
type CompactSessionStatus,
|
|
18
19
|
type JobInfo,
|
|
19
20
|
type HerdrWorkspace,
|
|
20
21
|
type FormAnswer,
|
|
@@ -1571,7 +1572,7 @@ export class Spoke {
|
|
|
1571
1572
|
try {
|
|
1572
1573
|
const snapshot = await activity.call(this.host, this.tracker.directories(SEED_DIRECTORIES))
|
|
1573
1574
|
const active = new Set(snapshot.active)
|
|
1574
|
-
const ids = new Set([...snapshot.active, ...snapshot.jobs.map((j) => j.sessionID), ...this.tracker.working()])
|
|
1575
|
+
const ids = new Set([...snapshot.active, ...snapshot.jobs.map((j) => j.sessionID), ...this.tracker.working(), ...this.tracker.failed()])
|
|
1575
1576
|
const now = Date.now()
|
|
1576
1577
|
for (const id of ids) {
|
|
1577
1578
|
if (active.has(id)) this.tracker.ensure(id, now)
|
|
@@ -1580,9 +1581,12 @@ export class Spoke {
|
|
|
1580
1581
|
.filter((j) => j.sessionID === id && j.status === "running")
|
|
1581
1582
|
.slice(0, SEED_JOBS_MAX)
|
|
1582
1583
|
.map(({ sessionID: _, ...job }) => job)
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
const
|
|
1584
|
+
// An active session waiting to retry is seeded with its retry (attempt, reason, next try); the last failure
|
|
1585
|
+
// (no run started since) goes along so a reconnected hub still shows it.
|
|
1586
|
+
const status: CompactSessionStatus = active.has(id) ? (this.tracker.retryOf(id) ?? { type: "busy" }) : { type: "idle" }
|
|
1587
|
+
const error = this.tracker.errorOf(id)
|
|
1588
|
+
this.tracker.seed(id, { status: status.type, jobs })
|
|
1589
|
+
const event = { type: "session.seed", data: { sessionID: id, status, jobs, ...(error ? { error } : {}) } }
|
|
1586
1590
|
if (this.connection?.state === "connected" && shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
|
|
1587
1591
|
}
|
|
1588
1592
|
void this.fetchInfo()
|