@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 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.2",
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.2",
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.2",
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.2",
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.2",
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.2",
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.2",
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.2",
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-cli",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.7.2",
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.2",
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.2",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-protocol",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "private": true,
5
5
  "description": "Hub ↔ spoke wire protocol (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 progress, which names the
234
- * child session, goes out as a `session.tool.called` with `childSessionID` (a type every hub accepts).
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 = { readonly type: "idle" | "busy" } | { readonly type: "retry"; readonly attempt?: number; readonly message?: string }
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
- /** OpenCode's SessionStatus reduced to its type and (retry) attempt and short reason. */
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
- return { type: "retry", ...(attempt !== undefined ? { attempt } : {}), ...(message ? { message } : {}) }
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.2",
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-runtime-sdk",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "private": true,
5
5
  "description": "The Runtime interface `hub node` runtimes implement (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-sdk",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "private": true,
5
5
  "description": "Typed client for the hub's /api/agent (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.2",
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",
@@ -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)
@@ -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
- if (!this.store.pushPrefs(s.deviceID)[precise]) return false
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))
@@ -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") return this.doneWatch.started(rootID, root)
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
- if (ended === "failed") this.alert("failed", rootID, error ? { text: error } : {})
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) this.alert("retry", rootID, { text: [retry.attempt !== undefined ? `Attempt ${retry.attempt}` : "", retry.message ?? ""].filter(Boolean).join(": ") || "Retrying" })
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)
@@ -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. */
@@ -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
- const status = active.has(id) ? "busy" : "idle"
1584
- this.tracker.seed(id, { status, jobs })
1585
- const event = { type: "session.seed", data: { sessionID: id, status: { type: status }, jobs } }
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()