@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.
- package/CHANGELOG.md +87 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +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/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- 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/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- 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
|
|
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.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",
|
|
@@ -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 (
|
|
300
|
-
* `signal` aborts (which ends the iteration quietly). blocked: only blocker events; session: one
|
|
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"
|
package/packages/sdk/src/sse.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
+
}
|