@huynle/hub 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/protocol/src/harness-events.ts +2 -2
  142. package/packages/protocol/src/index.ts +70 -7
  143. package/packages/runtime-openshell/package.json +1 -1
  144. package/packages/runtime-sdk/package.json +1 -1
  145. package/packages/sdk/package.json +1 -1
  146. package/packages/sdk/src/client.ts +55 -4
  147. package/packages/sdk/src/errors.ts +1 -0
  148. package/packages/sdk/src/sse.ts +4 -1
  149. package/packages/sdk/src/types.ts +59 -0
  150. package/packages/spoke-sdk/package.json +1 -1
  151. package/packages/spoke-sdk/src/enroll.ts +26 -4
  152. package/src/central/agent/app-tokens.ts +27 -17
  153. package/src/central/agent/archive-inactive.ts +66 -0
  154. package/src/central/agent/directory-routes.ts +155 -12
  155. package/src/central/agent/directory.ts +1 -1
  156. package/src/central/agent/errors.ts +2 -0
  157. package/src/central/agent/event-cursor.ts +49 -0
  158. package/src/central/agent/event-writers.ts +181 -0
  159. package/src/central/agent/events-routes.ts +148 -0
  160. package/src/central/agent/events.ts +277 -0
  161. package/src/central/agent/names.ts +1 -1
  162. package/src/central/agent/read-routes.ts +32 -36
  163. package/src/central/agent/routes.ts +14 -11
  164. package/src/central/agent/watch.ts +65 -174
  165. package/src/central/agent/webhook-routes.ts +10 -2
  166. package/src/central/agent/webhooks.ts +49 -5
  167. package/src/central/auth.ts +127 -1
  168. package/src/central/backend/index.ts +3 -0
  169. package/src/central/backend/types.ts +2 -0
  170. package/src/central/http.ts +116 -32
  171. package/src/central/mcp-http.ts +116 -0
  172. package/src/central/push.ts +14 -1
  173. package/src/central/registry.ts +34 -5
  174. package/src/central/store.ts +60 -0
  175. package/src/config.ts +70 -7
  176. package/src/index.ts +90 -91
  177. package/src/spoke/launchers.ts +109 -3
  178. package/src/spoke/spoke.ts +8 -4
  179. package/src/spoke/timeline.ts +59 -3
  180. package/src/spoke/tracker.ts +59 -6
  181. package/src/status.ts +97 -6
  182. package/web/dist/apple-touch-icon.png +0 -0
  183. package/web/dist/assets/index-Dm8OmkaO.css +1 -0
  184. package/web/dist/assets/index-FUJbj_Du.js +20 -0
  185. package/web/dist/favicon.ico +0 -0
  186. package/web/dist/icon-192.png +0 -0
  187. package/web/dist/icon-512.png +0 -0
  188. package/web/dist/icon-maskable-512.png +0 -0
  189. package/web/dist/icon.svg +5 -5
  190. package/web/dist/index.html +10 -4
  191. package/web/dist/manifest.webmanifest +2 -2
  192. package/web/dist/sw.js +4 -1
  193. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  194. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -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.1",
3
+ "version": "0.7.3",
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.1",
3
+ "version": "0.7.3",
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.1",
3
+ "version": "0.7.3",
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",
@@ -5,6 +5,9 @@ import { sseEvents } from "./sse.ts"
5
5
  import type {
6
6
  Approved,
7
7
  AppTokenView,
8
+ Archived,
9
+ ArchiveInactivePreview,
10
+ ArchiveInactiveResult,
8
11
  Asked,
9
12
  CreatedAppToken,
10
13
  Decision,
@@ -30,6 +33,7 @@ import type {
30
33
  Named,
31
34
  PromptDelivery,
32
35
  Prompted,
36
+ Pinned,
33
37
  Replied,
34
38
  RoleClaim,
35
39
  RoleRelease,
@@ -40,7 +44,10 @@ import type {
40
44
  Status,
41
45
  Stopped,
42
46
  WatchEvent,
47
+ EventsPage,
43
48
  Whoami,
49
+ WebhookDelivery,
50
+ WebhookView,
44
51
  } from "./types.ts"
45
52
 
46
53
  const AGENT = "/api/agent"
@@ -296,15 +303,16 @@ export class HubClient {
296
303
  }
297
304
 
298
305
  /**
299
- * GET /api/agent/watch: the events (session.*, blocker.*, …) as they happen, until the hub closes the stream or
300
- * `signal` aborts (which ends the iteration quietly). blocked: only blocker events; session: one address.
306
+ * GET /api/agent/watch: the journal's compact events (the same as events()) as they happen, until the hub closes
307
+ * the stream or `signal` aborts (which ends the iteration quietly). blocked: only blocker events; session: one
308
+ * address; cursor: resume after an event's `id` (or an events() cursor).
301
309
  */
302
- async *watch(options: CallOptions & { readonly blocked?: boolean; readonly session?: string } = {}): AsyncGenerator<WatchEvent, void, undefined> {
310
+ async *watch(options: CallOptions & { readonly blocked?: boolean; readonly session?: string; readonly cursor?: string } = {}): AsyncGenerator<WatchEvent, void, undefined> {
303
311
  const { signal } = options
304
312
  if (signal?.aborted) return
305
313
  let response: Response
306
314
  try {
307
- response = await this.request("GET", `${AGENT}/watch`, { query: { blocked: options.blocked ? "1" : undefined, session: options.session }, ...signalOf(options) })
315
+ response = await this.request("GET", `${AGENT}/watch`, { query: { blocked: options.blocked ? "1" : undefined, session: options.session, cursor: options.cursor }, ...signalOf(options) })
308
316
  } catch (cause) {
309
317
  if (signal?.aborted) return
310
318
  throw cause
@@ -312,6 +320,49 @@ export class HubClient {
312
320
  yield* sseEvents(response, signal)
313
321
  }
314
322
 
323
+ /**
324
+ * GET /api/agent/events: the compact events after `cursor` (none: start at the latest). wait: seconds (0–60) to
325
+ * hold while nothing matches; limit 1–200; types (exact, or `launch.*`) and sessions filter but still advance the
326
+ * cursor. App tokens need events:subscribe plus each family's scope.
327
+ */
328
+ events(options: CallOptions & { readonly cursor?: string; readonly wait?: number; readonly limit?: number; readonly types?: readonly string[]; readonly sessions?: readonly string[] } = {}): Promise<EventsPage> {
329
+ return this.call("GET", `${AGENT}/events`, { query: { cursor: options.cursor, wait: options.wait, limit: options.limit, type: options.types, session: options.sessions }, ...signalOf(options) })
330
+ }
331
+
332
+ webhookCreate(input: { readonly url: string; readonly events?: readonly string[]; readonly label?: string }, options: CallOptions = {}): Promise<{ webhook: WebhookView; secret: string }> {
333
+ return this.call("POST", `${AGENT}/webhooks`, { body: input, ...signalOf(options) })
334
+ }
335
+
336
+ webhooks(options: CallOptions = {}): Promise<{ webhooks: WebhookView[] }> {
337
+ return this.call("GET", `${AGENT}/webhooks`, signalOf(options))
338
+ }
339
+
340
+ webhook(id: string, options: CallOptions = {}): Promise<{ webhook: WebhookView; deliveries: WebhookDelivery[] }> {
341
+ return this.call("GET", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
342
+ }
343
+
344
+ webhookDelete(id: string, options: CallOptions = {}): Promise<void> {
345
+ return this.call("DELETE", `${AGENT}/webhooks/${segment(id)}`, signalOf(options))
346
+ }
347
+
348
+ // Organization (sessions:manage).
349
+
350
+ setArchived(addr: string, archived: boolean, options: CallOptions = {}): Promise<Archived> {
351
+ return this.call("PUT", `${AGENT}/sessions/${segment(addr)}/archive`, { body: { archived }, ...signalOf(options) })
352
+ }
353
+
354
+ setPinned(addr: string, pinned: boolean, options: CallOptions = {}): Promise<Pinned> {
355
+ return this.call("PUT", `${AGENT}/sessions/${segment(addr)}/pin`, { body: { pinned }, ...signalOf(options) })
356
+ }
357
+
358
+ previewArchiveInactive(inactiveFor: string, limit: number, options: CallOptions = {}): Promise<ArchiveInactivePreview> {
359
+ return this.call("POST", `${AGENT}/sessions/archive-inactive`, { body: { preview: true, inactiveFor, limit }, ...signalOf(options) })
360
+ }
361
+
362
+ archiveInactive(previewToken: string, options: CallOptions = {}): Promise<ArchiveInactiveResult> {
363
+ return this.call("POST", `${AGENT}/sessions/archive-inactive`, { body: { previewToken, confirm: true }, ...signalOf(options) })
364
+ }
365
+
315
366
  // Naming.
316
367
 
317
368
  name(name: string, options: TargetOptions = {}): Promise<Named> {
@@ -6,6 +6,7 @@ import type { Evaluation } from "./types.ts"
6
6
  /** The codes the hub answers with (src/central/agent/errors.ts); later phases may add more, so any string is kept. */
7
7
  export type ErrorCode =
8
8
  | "invalid"
9
+ | "invalid_cursor"
9
10
  | "unauthorized"
10
11
  | "token_expired"
11
12
  | "needs_user"
@@ -5,6 +5,8 @@ import type { WatchEvent } from "./types.ts"
5
5
  /** Parses SSE text incrementally: feed chunks, get complete events (events without data are skipped). */
6
6
  export class SseParser {
7
7
  #buffer = ""
8
+ /** The last `id:` seen (it carries over to later events, as in EventSource). */
9
+ #id = ""
8
10
 
9
11
  push(chunk: string): WatchEvent[] {
10
12
  this.#buffer += chunk.replace(/\r\n?/g, "\n")
@@ -22,8 +24,9 @@ export class SseParser {
22
24
  const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "")
23
25
  if (field === "event") event = value
24
26
  else if (field === "data") data.push(value)
27
+ else if (field === "id" && !value.includes("\0")) this.#id = value
25
28
  }
26
- if (data.length) out.push({ event, data: data.join("\n") })
29
+ if (data.length) out.push({ event, data: data.join("\n"), ...(this.#id ? { id: this.#id } : {}) })
27
30
  }
28
31
  return out
29
32
  }
@@ -121,6 +121,43 @@ export interface DirectoryEntry {
121
121
  readonly roles: readonly RoleView[]
122
122
  }
123
123
 
124
+ /** One compact event of GET /api/agent/events (no free text). Unknown types are valid: ignore them. */
125
+ export interface AgentEvent {
126
+ readonly id: string
127
+ readonly at: number
128
+ readonly type: string
129
+ readonly subject: Readonly<Record<string, string>>
130
+ readonly data: Readonly<Record<string, string | number | boolean>>
131
+ }
132
+
133
+ /**
134
+ * A page of GET /api/agent/events: pass `cursor` to the next call. `more`: records remain unread. `reset`: the
135
+ * cursor was no longer covered (expired, trimmed, hub restarted); re-read the `refresh` snapshots, then go on.
136
+ */
137
+ export interface EventsPage {
138
+ readonly events: AgentEvent[]
139
+ readonly cursor: string
140
+ readonly more: boolean
141
+ readonly reset: boolean
142
+ readonly refresh?: string[]
143
+ }
144
+
145
+ export interface Archived {
146
+ readonly sessionID: string
147
+ readonly archived: boolean
148
+ readonly changed: boolean
149
+ }
150
+
151
+ export interface Pinned {
152
+ readonly sessionID: string
153
+ readonly pinned: boolean
154
+ readonly changed: boolean
155
+ }
156
+
157
+ export type ArchiveSkipReason = "active" | "pinned" | "open" | "archived" | "not_found"
158
+ export interface ArchiveInactivePreview { readonly candidates: readonly string[]; readonly previewToken: string; readonly expiresAt: number }
159
+ export interface ArchiveInactiveResult { readonly changed: readonly string[]; readonly skipped: readonly { readonly sessionID: string; readonly reason: ArchiveSkipReason }[] }
160
+
124
161
  export interface Named {
125
162
  readonly sessionID: string
126
163
  readonly name: string
@@ -271,6 +308,8 @@ export interface Inbox {
271
308
  export interface WatchEvent {
272
309
  readonly event: string
273
310
  readonly data: string
311
+ /** The stream's cursor at this event (GET /api/agent/watch?cursor= resumes after it). */
312
+ readonly id?: string
274
313
  }
275
314
 
276
315
  // Acting (POST /api/agent/sessions/:addr/{approve,deny,answer,prompt,stop}; src/central/agent/acting.ts).
@@ -430,6 +469,26 @@ export interface LaunchArtifacts {
430
469
  readonly dirty?: boolean
431
470
  }
432
471
 
472
+ export interface WebhookView {
473
+ readonly id: string
474
+ readonly url: string
475
+ readonly events: readonly string[]
476
+ readonly by: string
477
+ readonly label?: string
478
+ readonly created: number
479
+ }
480
+
481
+ export interface WebhookDelivery {
482
+ readonly id: string
483
+ readonly event: string
484
+ readonly type: string
485
+ readonly state: "pending" | "delivered" | "failed"
486
+ readonly attempts: number
487
+ readonly created: number
488
+ readonly nextAt?: number
489
+ readonly last?: { readonly at: number; readonly status?: number; readonly error?: string }
490
+ }
491
+
433
492
  /** A launch as the hub reports it (the spec with its defaults filled in). */
434
493
  export interface LaunchView {
435
494
  readonly id: string
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",
@@ -10,19 +10,40 @@ export interface EnrollOptions {
10
10
  readonly signal?: AbortSignal
11
11
  readonly fetch?: typeof fetch
12
12
  readonly now?: () => number
13
+ /**
14
+ * "follow" (default) follows HTTP redirects. "error" refuses them: a redirect could send the device code to
15
+ * another origin or downgrade to plain http. A refused redirect throws EnrollmentRefused, which polling does not
16
+ * retry.
17
+ */
18
+ readonly redirect?: "follow" | "error"
19
+ /**
20
+ * The hub hands out an approved credential exactly once. With keepRedeemed, `signal` never aborts a poll request
21
+ * in flight (its reply may carry that credential); it still stops polling before the next request.
22
+ */
23
+ readonly keepRedeemed?: boolean
24
+ }
25
+
26
+ /** The hub answered in a way the caller's policy refuses (a redirect under redirect: "error"); not retried. */
27
+ export class EnrollmentRefused extends Error {
28
+ override readonly name = "EnrollmentRefused"
13
29
  }
14
30
 
15
31
  const REQUEST_TIMEOUT_MS = 15_000
16
32
 
17
- async function post(server: string, path: string, body: unknown, options: EnrollOptions): Promise<unknown> {
33
+ async function post(server: string, path: string, body: unknown, options: EnrollOptions, abortable = true): Promise<unknown> {
18
34
  const doFetch = options.fetch ?? fetch
19
- const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
35
+ const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal && abortable ? [options.signal] : [])]
36
+ const strict = options.redirect === "error"
20
37
  const response = await doFetch(`${server.replace(/\/+$/, "")}${path}`, {
21
38
  method: "POST",
22
39
  headers: { "content-type": "application/json" },
23
40
  body: JSON.stringify(body),
24
41
  signal: AbortSignal.any(signals),
42
+ ...(strict ? { redirect: "manual" as const } : {}),
25
43
  })
44
+ if (strict && (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400))) {
45
+ throw new EnrollmentRefused(`${path} was redirected; enrollment does not follow redirects`)
46
+ }
26
47
  if (!response.ok) throw new Error(`${path} failed: HTTP ${response.status}`)
27
48
  return response.json()
28
49
  }
@@ -67,8 +88,9 @@ export async function pollEnrollment(server: string, start: EnrollStart, options
67
88
  if (options.signal?.aborted) return { status: "cancelled" }
68
89
  let reply: Partial<EnrollPoll> & Record<string, unknown>
69
90
  try {
70
- reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options)) as typeof reply
71
- } catch {
91
+ reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options, !options.keepRedeemed)) as typeof reply
92
+ } catch (cause) {
93
+ if (cause instanceof EnrollmentRefused) throw cause
72
94
  continue
73
95
  }
74
96
  if (reply?.status === "approved" && typeof reply.machineID === "string" && typeof reply.machineToken === "string") {
@@ -19,8 +19,10 @@ import type { AgentRoute } from "./routes"
19
19
  * acting on sessions (approve, deny, answer, prompt, stop) and launching sessions (phase 4), each of which still
20
20
  * needs a grant held by the token (with the act verb, or `launch` covering the repo).
21
21
  */
22
- export const APP_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read", "sessions:act", "sessions:launch"]
22
+ export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe"]
23
23
  export const DEFAULT_APP_SCOPES: readonly string[] = ["sessions:read"]
24
+ /** What `hub login` (the CLI login device code) asks for unless it asks for more: reading sessions and messaging. */
25
+ export const DEFAULT_CLI_LOGIN_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read"]
24
26
  export const MAX_APP_TOKENS = 50
25
27
  export const MAX_APP_TOKEN_LABEL = 100
26
28
 
@@ -88,6 +90,29 @@ const view = (token: AppToken) => ({
88
90
  ...(typeof token.created === "number" ? { created: token.created } : {}),
89
91
  })
90
92
 
93
+ /**
94
+ * Issues an app token of `userID` (the POST route below, and an approved CLI login): the record stored (sha256 of the
95
+ * token only) and the token, which the caller hands out once and never logs. rate_limited at MAX_APP_TOKENS.
96
+ */
97
+ export async function issueAppToken(store: Store, userID: string, input: { readonly label?: string; readonly scopes: readonly string[] }): Promise<{ record: AppToken; token: string }> {
98
+ const id = randomBytes(8).toString("hex")
99
+ const token = `hat_${id}_${randomBytes(32).toString("base64url")}`
100
+ const record: AppToken = { id, tokenHash: hashToken(token), ...(input.label ? { label: input.label } : {}), scopes: [...input.scopes], created: Date.now() }
101
+ let full = false
102
+ await store.updateAgentData(userID, (data) => {
103
+ const tokens = data.appTokens ?? []
104
+ full = false
105
+ if (tokens.length >= MAX_APP_TOKENS) {
106
+ full = true
107
+ return
108
+ }
109
+ data.appTokens = [...tokens, record]
110
+ })
111
+ if (full) throw new AgentError("rate_limited", `At most ${MAX_APP_TOKENS} app tokens per user`, { hint: "Revoke unused tokens" })
112
+ await store.audit({ actor: userID, action: "app-token.create", target: id, ...(input.label ? { detail: input.label } : {}) })
113
+ return { record, token }
114
+ }
115
+
91
116
  /** POST, GET and DELETE /api/agent/app-tokens: user actors only. */
92
117
  export function appTokenRoutes(store: Store): AgentRoute[] {
93
118
  return [
@@ -96,22 +121,7 @@ export function appTokenRoutes(store: Store): AgentRoute[] {
96
121
  path: PREFIX,
97
122
  allow: {},
98
123
  async handle({ request, user }) {
99
- const input = parseCreate(await request.json())
100
- const id = randomBytes(8).toString("hex")
101
- const token = `hat_${id}_${randomBytes(32).toString("base64url")}`
102
- const record: AppToken = { id, tokenHash: hashToken(token), ...(input.label ? { label: input.label } : {}), scopes: input.scopes, created: Date.now() }
103
- let full = false
104
- await store.updateAgentData(user.id, (data) => {
105
- const tokens = data.appTokens ?? []
106
- full = false
107
- if (tokens.length >= MAX_APP_TOKENS) {
108
- full = true
109
- return
110
- }
111
- data.appTokens = [...tokens, record]
112
- })
113
- if (full) throw new AgentError("rate_limited", `At most ${MAX_APP_TOKENS} app tokens per user`, { hint: "Revoke unused tokens" })
114
- await store.audit({ actor: user.id, action: "app-token.create", target: id, ...(input.label ? { detail: input.label } : {}) })
124
+ const { record, token } = await issueAppToken(store, user.id, parseCreate(await request.json()))
115
125
  return Response.json({ ...view(record), token }, { status: 201, headers: NO_STORE })
116
126
  },
117
127
  },
@@ -0,0 +1,66 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto"
2
+ import type { DirectoryEntry } from "./directory"
3
+ import { AgentError } from "./errors"
4
+
5
+ const PREFIX = "hai1_"
6
+ const DOMAIN = "hub-archive-inactive/v1"
7
+ const TTL_MS = 5 * 60_000
8
+ const invalid = () => new AgentError("invalid", "This inactive-archive preview is invalid or expired; preview again")
9
+
10
+ interface Payload { readonly userID: string; readonly at: number; readonly inactiveForMs: number; readonly candidates: readonly string[] }
11
+ export interface ArchiveSets { readonly pinned: ReadonlySet<string>; readonly archived: ReadonlySet<string> }
12
+ export type SkipReason = "active" | "pinned" | "open" | "archived" | "not_found"
13
+
14
+ export class ArchiveInactive {
15
+ constructor(private readonly options: { readonly secret: Buffer; readonly now?: () => number }) {}
16
+ private now() { return (this.options.now ?? Date.now)() }
17
+
18
+ preview(userID: string, sessions: readonly DirectoryEntry[], options: ArchiveSets & { readonly inactiveForMs: number; readonly limit: number }) {
19
+ const candidates = sessions
20
+ .filter((s) => !s.online && typeof s.lastSeen === "number" && this.now() - s.lastSeen >= options.inactiveForMs && !options.pinned.has(s.sessionID) && !options.archived.has(s.sessionID))
21
+ .map((s) => s.sessionID).slice(0, options.limit)
22
+ const payload: Payload = { userID, at: this.now(), inactiveForMs: options.inactiveForMs, candidates }
23
+ return { candidates, previewToken: this.encode(payload), expiresAt: payload.at + TTL_MS }
24
+ }
25
+
26
+ decode(userID: string, token: string): Payload {
27
+ if (!token.startsWith(PREFIX) || token.length > 10_000) throw invalid()
28
+ const [body, tag, ...rest] = token.slice(PREFIX.length).split(".")
29
+ if (rest.length || !body || !tag) throw invalid()
30
+ const expected = createHmac("sha256", this.options.secret).update(`${DOMAIN}\n${body}`).digest().subarray(0, 16)
31
+ const given = Buffer.from(tag, "base64url")
32
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) throw invalid()
33
+ let payload: Payload
34
+ try { payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as Payload } catch { throw invalid() }
35
+ if (payload.userID !== userID || !Array.isArray(payload.candidates) || this.now() - payload.at > TTL_MS) throw invalid()
36
+ return payload
37
+ }
38
+
39
+ revalidate(userID: string, token: string, sessions: readonly DirectoryEntry[], options: ArchiveSets & { readonly open: ReadonlySet<string>; readonly active: ReadonlySet<string> }) {
40
+ const payload = this.decode(userID, token)
41
+ const byID = new Map(sessions.map((s) => [s.sessionID, s]))
42
+ const candidates: string[] = []
43
+ const skipped: Array<{ sessionID: string; reason: SkipReason }> = []
44
+ for (const sessionID of payload.candidates) {
45
+ const s = byID.get(sessionID)
46
+ const reason: SkipReason | undefined = !s ? "not_found" : options.archived.has(sessionID) ? "archived" : options.pinned.has(sessionID) ? "pinned" : options.open.has(sessionID) ? "open" : options.active.has(sessionID) || s.online ? "active" : undefined
47
+ if (reason) skipped.push({ sessionID, reason }); else candidates.push(sessionID)
48
+ }
49
+ return { candidates, skipped }
50
+ }
51
+
52
+ private encode(payload: Payload) {
53
+ const body = Buffer.from(JSON.stringify(payload)).toString("base64url")
54
+ const tag = createHmac("sha256", this.options.secret).update(`${DOMAIN}\n${body}`).digest().subarray(0, 16).toString("base64url")
55
+ return `${PREFIX}${body}.${tag}`
56
+ }
57
+ }
58
+
59
+ export function parseInactiveFor(value: unknown): number {
60
+ if (typeof value !== "string") throw new AgentError("invalid", "inactiveFor must be a duration such as 7d")
61
+ const m = /^(\d+)(h|d)$/.exec(value)
62
+ if (!m) throw new AgentError("invalid", "inactiveFor must be a duration such as 7d")
63
+ const ms = Number(m[1]) * (m[2] === "d" ? 86_400_000 : 3_600_000)
64
+ if (ms < 3_600_000 || ms > 365 * 86_400_000) throw new AgentError("invalid", "inactiveFor must be from 1h to 365d")
65
+ return ms
66
+ }