@huynle/hub 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +19 -5
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/sessions.ts +4 -1
  6. package/packages/cli/dist/hub-cli.js +240 -83
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +12 -7
  9. package/packages/cli/src/decisions.ts +52 -4
  10. package/packages/cli/src/format.ts +9 -0
  11. package/packages/cli/src/mcp.ts +20 -4
  12. package/packages/cli/src/team-intake-mcp.ts +2 -1
  13. package/packages/cli/src/teams-mcp.ts +7 -3
  14. package/packages/codex/package.json +1 -1
  15. package/packages/codex/src/sessions.ts +14 -3
  16. package/packages/node/package.json +1 -1
  17. package/packages/preset/package.json +1 -1
  18. package/packages/protocol/package.json +1 -1
  19. package/packages/protocol/schema/api/v1/_root.json +119 -0
  20. package/packages/protocol/schema/api/v1/account.json +419 -0
  21. package/packages/protocol/schema/api/v1/decisions.json +635 -0
  22. package/packages/protocol/schema/api/v1/discovery.json +110 -0
  23. package/packages/protocol/schema/api/v1/events.json +679 -0
  24. package/packages/protocol/schema/api/v1/session-actions.json +660 -0
  25. package/packages/protocol/schema/api/v1/sessions.json +2055 -0
  26. package/packages/protocol/schema/api/v1/speech.json +492 -0
  27. package/packages/protocol/schema/api/v1/unspecified.json +282 -0
  28. package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
  29. package/packages/protocol/schema/app-front/v1.schema.json +7 -3
  30. package/packages/protocol/src/api-v1.ts +8 -0
  31. package/packages/protocol/src/index.ts +2 -0
  32. package/packages/protocol/src/teams.ts +13 -1
  33. package/packages/runtime-openshell/package.json +1 -1
  34. package/packages/runtime-sdk/package.json +1 -1
  35. package/packages/sdk/package.json +1 -1
  36. package/packages/sdk/src/app-front.ts +2 -2
  37. package/packages/sdk/src/ask-fields.ts +76 -0
  38. package/packages/sdk/src/client.ts +11 -2
  39. package/packages/sdk/src/envelope.ts +3 -2
  40. package/packages/sdk/src/index.ts +1 -0
  41. package/packages/sdk/src/team-intake.ts +6 -0
  42. package/packages/sdk/src/teams.ts +2 -0
  43. package/packages/sdk/src/types.ts +36 -4
  44. package/packages/sdk/src/wake.ts +9 -4
  45. package/packages/spoke-sdk/package.json +1 -1
  46. package/src/central/agent/agent-limits.ts +185 -0
  47. package/src/central/agent/app-decisions.ts +6 -5
  48. package/src/central/agent/decisions.ts +130 -9
  49. package/src/central/agent/delivery.ts +134 -22
  50. package/src/central/agent/errors.ts +2 -1
  51. package/src/central/agent/events.ts +20 -2
  52. package/src/central/agent/launch-engine.ts +6 -3
  53. package/src/central/agent/message-limits.ts +5 -2
  54. package/src/central/agent/message-routes.ts +33 -6
  55. package/src/central/agent/messages.ts +7 -0
  56. package/src/central/agent/placement.ts +4 -2
  57. package/src/central/agent/read-routes.ts +5 -0
  58. package/src/central/agent/routes.ts +2 -0
  59. package/src/central/agent/subscriptions.ts +13 -4
  60. package/src/central/api-v1.ts +115 -0
  61. package/src/central/artifacts/store.ts +4 -2
  62. package/src/central/concierge/board-results.ts +2 -1
  63. package/src/central/concierge/notifier.ts +4 -2
  64. package/src/central/config.ts +20 -6
  65. package/src/central/health/classify.ts +209 -0
  66. package/src/central/http.ts +285 -23
  67. package/src/central/recording/index.ts +2 -1
  68. package/src/central/recording/schema.ts +2 -1
  69. package/src/central/registry.ts +34 -3
  70. package/src/central/teams/budgets.ts +144 -0
  71. package/src/central/teams/hop-stalls.ts +14 -6
  72. package/src/central/teams/intake.ts +38 -6
  73. package/src/central/teams/involvement.ts +95 -0
  74. package/src/central/teams/item-decisions.ts +60 -0
  75. package/src/central/teams/item-eligibility.ts +35 -0
  76. package/src/central/teams/item-stalls.ts +87 -0
  77. package/src/central/teams/loops.ts +40 -0
  78. package/src/central/teams/progress.ts +154 -0
  79. package/src/central/teams/routes.ts +2 -2
  80. package/src/central/teams/store.ts +21 -5
  81. package/src/central/teams/validation.ts +17 -6
  82. package/src/central/workspaces/coordinator.ts +3 -1
  83. package/src/spoke/hub-tool.ts +26 -11
  84. package/web/dist/assets/{ArtifactPage-C5MMqXLN.js → ArtifactPage-TbFJi59n.js} +1 -1
  85. package/web/dist/assets/{ArtifactPane-CXSCmhyJ.js → ArtifactPane-7D-M-Ich.js} +1 -1
  86. package/web/dist/assets/{ArtifactWorkbench-S0RxNGWy.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
  87. package/web/dist/assets/{Diagram-BGmyRb-R.js → Diagram-PvOPetOI.js} +1 -1
  88. package/web/dist/assets/{Library-CFmMbXEa.js → Library-Cdi83pOV.js} +2 -2
  89. package/web/dist/assets/{ProfilesDestination-7jpOc2HM.js → ProfilesDestination-cryC7P6j.js} +1 -1
  90. package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
  91. package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
  92. package/web/dist/assets/index-DA0gKIzv.js +33 -0
  93. package/web/dist/assets/live-B2IfKm37.js +5 -0
  94. package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
  95. package/web/dist/assets/{renderer2d-B2Nh39UN.js → renderer2d-CsZnF3_o.js} +1 -1
  96. package/web/dist/assets/{renderer3d-CTYbljHH.js → renderer3d-BIMA0wFn.js} +2 -2
  97. package/web/dist/assets/{safe-area-CkhEclGo.js → safe-area-dtrnw9NF.js} +1 -1
  98. package/web/dist/index.html +1 -1
  99. package/web/dist/assets/TeamsPage-C1NGrUR-.js +0 -1
  100. package/web/dist/assets/Watchtower-DnvlJaU0.js +0 -2
  101. package/web/dist/assets/index-Da8SKXMw.js +0 -33
  102. package/web/dist/assets/live-BmqLASSo.js +0 -5
@@ -0,0 +1,209 @@
1
+ // (SS-1, plan v5ms8lgq) A session's health from its status board entry: why it is not making progress, next to the
2
+ // derived state (which stays "working" during a retry). Pure. Typed errors (OpenCode's errorType/errorStatus) decide
3
+ // first; message patterns cover the harnesses that send only text (Claude Code, Codex). An unknown failure is
4
+ // "failed", never a false quota.
5
+ import { providerFailureNeedsUser, type RetryInfo } from "../../status"
6
+
7
+ export type HealthKind = "ok" | "retrying" | "stalled" | "quota" | "auth" | "model_unavailable" | "context_overflow" | "network" | "failed"
8
+ export const HEALTH_KINDS: readonly HealthKind[] = ["ok", "retrying", "stalled", "quota", "auth", "model_unavailable", "context_overflow", "network", "failed"]
9
+
10
+ /** (Decision 3kj1d70k) A transient retry counts as stalled after this many attempts or this long since its first retry. */
11
+ export const STALL_ATTEMPTS = 3
12
+ export const STALL_MS = 5 * 60_000
13
+ export const HEALTH_DETAIL_MAX = 200
14
+ /** Only the start of a provider message is examined: causes come first, and the patterns' work stays bounded. */
15
+ export const CAUSE_TEXT_MAX = 2000
16
+
17
+ export interface SessionHealth {
18
+ readonly kind: HealthKind
19
+ /** When this kind was first seen (set by the registry, which keeps the history). */
20
+ readonly since?: number
21
+ /** quota: when the provider says it resets (epoch ms), when the message says. */
22
+ readonly resetAt?: number
23
+ /** While retrying: the provider retry's attempt number. */
24
+ readonly attempt?: number
25
+ /** The provider's message, sanitised (≤ 200 characters, secrets redacted). */
26
+ readonly detail?: string
27
+ }
28
+
29
+ export interface HealthInput {
30
+ readonly state: string
31
+ /** state "error": the error's message (the status board's detail). */
32
+ readonly detail?: string
33
+ readonly retry?: RetryInfo
34
+ readonly errorType?: string
35
+ readonly errorStatus?: number
36
+ }
37
+
38
+ /** A cause that retrying will not fix: the session stays stuck until someone acts. */
39
+ type Hard = "quota" | "auth" | "model_unavailable" | "context_overflow"
40
+ type Cause = { readonly kind: Hard | "network"; readonly resetAt?: number }
41
+
42
+ // SS-2: Codex's UsageLimitReached ("You’ve hit your usage limit", "out of credits", "spend cap") and Claude Code's
43
+ // subscription limits ("You've hit your limit · resets 3pm", "5-hour limit reached") and billing_error.
44
+ const QUOTA_WORDS = /\bquota\b|usage limit|insufficient[_ ]quota|billing|credit balance|out of credits|spend(?:ing)? (?:limit|cap)|budget (?:limit|exceeded)|hit your limit|\b(?:\d+-hour|weekly|daily|session) limit reached/i
45
+ const RATE_LIMIT = /\b429\b|rate[ -]?limit|too many requests/i
46
+ const AUTH = /\b40[13]\b|unauthori[sz]ed|forbidden|invalid (?:x-)?api[ -_]?key|authentication|not (?:logged|signed) in|(?:token|credentials?) (?:has )?expired|permission denied|oauth[_ ]org[_ ]not[_ ]allowed|account[_ ]on[_ ]hold|account (?:is )?(?:suspended|disabled)|cloud[_ ]credential/i
47
+ const MODEL = /\bmodel\b[^.\n]{0,60}?\b(?:not found|does not exist|doesn't exist|is not available|unavailable|not supported|unsupported)\b|unknown model|no such model|model_not_found|invalid model|\b404\b[^\n]{0,160}\bmodel\b/i
48
+ const CONTEXT = /context (?:length|window)|maximum context|too many tokens|prompt is too long|input is too long|context_length_exceeded|exceeds? the (?:model's )?(?:context|token) limit/i
49
+ const NETWORK = /\bE(?:CONNRESET|CONNREFUSED|CONNABORTED|TIMEDOUT|NOTFOUND|AI_AGAIN|PIPE|HOSTUNREACH|NETUNREACH)\b|socket hang up|network error|fetch failed|connection (?:reset|refused|closed|failed|error)|error sending request|stream disconnected|timed out|\btimeout\b/i
50
+ /** A response the provider stopped by its content policy is no connection problem (Codex reports it as a disconnect). */
51
+ const CONTENT_POLICY = /content[_ ]filter|content[_ ]policy/i
52
+ /**
53
+ * (Review rule 3) Without a provider-error marker only an errno code may say "network": "timeout" alone is a build
54
+ * or a command as often as a provider, and client-library wording ("fetch failed", "error sending request for url",
55
+ * "stream disconnected") is generic. SS-9's typed harness errors carry the real network failures.
56
+ */
57
+ const CONNECTION = /\bE(?:CONNRESET|CONNREFUSED|CONNABORTED|TIMEDOUT|AI_AGAIN|HOSTUNREACH|NETUNREACH)\b/
58
+ /** A reset hint further ahead than this is not believed (a year). */
59
+ const RESET_MAX_MS = 365 * 24 * 3_600_000
60
+ /**
61
+ * (SS-9) The typed errors Claude Code (StopFailure's error token) and Codex (codexErrorInfo, snake_case) report: a
62
+ * kind, or "words" for a marker whose message decides (a rate limit whose detail says "usage limit" is a quota). Any
63
+ * other claude.* / codex.* type is failed.
64
+ */
65
+ const HARNESS_TYPES: Readonly<Record<string, Hard | "network" | "words">> = {
66
+ "claude.billing_error": "quota",
67
+ "claude.account_on_hold": "quota",
68
+ "claude.authentication_failed": "auth",
69
+ "claude.oauth_org_not_allowed": "auth",
70
+ "claude.cloud_credential_error": "auth",
71
+ "claude.model_not_found": "model_unavailable",
72
+ "claude.rate_limit": "words",
73
+ "claude.overloaded": "words",
74
+ "claude.invalid_request": "words",
75
+ "codex.usage_limit_exceeded": "quota",
76
+ "codex.unauthorized": "auth",
77
+ "codex.model_not_found": "model_unavailable",
78
+ "codex.context_window_exceeded": "context_overflow",
79
+ "codex.http_connection_failed": "network",
80
+ "codex.response_stream_connection_failed": "network",
81
+ "codex.response_stream_disconnected": "network",
82
+ "codex.rate_limit_exceeded": "words",
83
+ "codex.bad_request": "words",
84
+ }
85
+ const harnessType = (type: string | undefined): boolean => type !== undefined && (type.startsWith("claude.") || type.startsWith("codex."))
86
+ /** A rate limit whose reset is at least this far ahead is a quota, not a transient limit. */
87
+ const QUOTA_RESET_MS = 60 * 60_000
88
+
89
+ const UNIT_MS: Readonly<Record<string, number>> = { h: 3_600_000, m: 60_000, s: 1000 }
90
+ const RESET_HINT = /(?:reset(?:s|ting)?|try again|retry)\s+(?:after|in|at)\s+([^.;)\n]{1,60})/i
91
+ const DURATION = /(\d+(?:\.\d+)?)\s*(h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?|s(?:ec(?:ond)?s?)?)\b/gi
92
+
93
+ /** When the provider says the limit resets: "reset after 118h", "resets in 2h 15m", "retry after 90 seconds", or an ISO time. */
94
+ export function parseResetAt(message: string | undefined, now: number): number | undefined {
95
+ const hint = message?.match(RESET_HINT)?.[1]
96
+ if (!hint) return undefined
97
+ const iso = hint.match(/\d{4}-\d{2}-\d{2}T[\d:.]+(?:Z|[+-]\d{2}:?\d{2})?/)?.[0]
98
+ let at: number
99
+ if (iso) at = Date.parse(iso)
100
+ else {
101
+ let ms = 0
102
+ for (const [, amount, unit] of hint.matchAll(DURATION)) ms += Number(amount) * UNIT_MS[unit![0]!.toLowerCase()]!
103
+ if (!(ms > 0)) return undefined
104
+ at = now + Math.round(ms)
105
+ }
106
+ // A valid date, ahead, and within a year: anything else is not believed (and could not be shown as a date).
107
+ return Number.isFinite(at) && at > now && at - now <= RESET_MAX_MS && !Number.isNaN(new Date(at).getTime()) ? at : undefined
108
+ }
109
+
110
+ /** Secrets a provider message may echo back, each replaced by [redacted] (the label before it kept). */
111
+ const SECRETS: readonly (readonly [RegExp, string])[] = [
112
+ [/-----BEGIN [A-Z ]+-----[\s\S]*?(?:-----END [A-Z ]+-----|$)/g, "[redacted]"],
113
+ [/\b(Authorization|Proxy-Authorization)\s*[:=]\s*(?:Basic|Bearer|Token|Digest)?\s*[^\s,;]+/gi, "$1: [redacted]"],
114
+ [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+\/=-]{4,}/gi, "$1 [redacted]"],
115
+ [/\b([a-z][a-z0-9+.-]*:\/\/)[^\s\/@:]+:[^\s\/@]+@/gi, "$1[redacted]@"],
116
+ [/\b(Set-Cookie|Cookie)\s*:[^\r\n]*/gi, "$1: [redacted]"],
117
+ [/(hooks\.slack\.com\/(?:services|workflows|triggers)\/)[^\s"'<>)]+/gi, "$1[redacted]"],
118
+ [/\b(bot)?\d{8,10}:[A-Za-z0-9_-]{30,}/g, "$1[redacted]"],
119
+ // A key-like name's value: to its closing quote when quoted (spaces included), else to the next separator.
120
+ [/\b([\w-]*(?:api[_-]?key|key|token|secret|password|passwd|pwd|credential|signature|sig)[\w-]*)(["']?\s*[:=]\s*)(["'])(?:(?!\3)[^\r\n])*\3?/gi, "$1$2$3[redacted]$3"],
121
+ [/\b([\w-]*(?:api[_-]?key|key|token|secret|password|passwd|pwd|credential|signature|sig)[\w-]*)(["']?\s*[:=]\s*)(?!["'])[^\s"'&,;)]+/gi, "$1$2[redacted]"],
122
+ [/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g, "[redacted]"],
123
+ [/\b(?:AKIA|ASIA|AGPA|AIDA|AROA)[A-Z0-9]{16}\b/g, "[redacted]"],
124
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, "[redacted]"],
125
+ [/\b(?:sk|pk|rk|hst|hat|hcs|hca|hcr|ghp|gho|ghs|ghu|ghr|glpat|xox[abpr])[-_][A-Za-z0-9_-]{8,}/g, "[redacted]"],
126
+ [/\bAIza[A-Za-z0-9_-]{20,}/g, "[redacted]"],
127
+ [/\b(?:npm|hf)_[A-Za-z0-9]{20,}/g, "[redacted]"],
128
+ [/\b[0-9a-fA-F]{40,}\b/g, "[redacted]"],
129
+ // Any other long base64-like run (40+ characters): a token with no recognisable prefix.
130
+ [/[A-Za-z0-9+\/_-]{40,}={0,2}/g, "[redacted]"],
131
+ ]
132
+
133
+ /**
134
+ * The first line only, secrets redacted, no control or format characters, ≤ 200 characters. Prefer showing less:
135
+ * a provider message is untrusted and may echo credentials.
136
+ */
137
+ export function sanitizeHealthDetail(text: string | undefined): string | undefined {
138
+ if (!text) return undefined
139
+ let redacted = text.slice(0, CAUSE_TEXT_MAX)
140
+ for (const [pattern, replacement] of SECRETS) redacted = redacted.replace(pattern, replacement)
141
+ const firstLine = redacted.split(/\r?\n/).find((line) => line.trim() !== "") ?? ""
142
+ const clean = firstLine
143
+ .replace(/[\p{Cc}\p{Cf}]/gu, " ")
144
+ .replace(/\s+/g, " ")
145
+ .trim()
146
+ if (!clean) return undefined
147
+ return clean.length > HEALTH_DETAIL_MAX ? `${clean.slice(0, HEALTH_DETAIL_MAX - 1)}…` : clean
148
+ }
149
+
150
+ /**
151
+ * What a typed or worded error means, when it means something specific; undefined for a transient or unknown one.
152
+ * `marked`: the error carries a provider-error marker (a provider.* type, an HTTP status, or it is a provider retry).
153
+ * Without one (Claude Code and Codex send text only until SS-9's typed token), the words alone never give a
154
+ * provider kind: only an unmistakable connection error says network, everything else is failed.
155
+ */
156
+ function cause(type: string | undefined, status: number | undefined, message: string | undefined, now: number, marked: boolean): Cause | undefined {
157
+ const text = (message ?? "").slice(0, CAUSE_TEXT_MAX)
158
+ const resetAt = parseResetAt(text, now)
159
+ const quota = (): Cause => ({ kind: "quota", ...(resetAt !== undefined ? { resetAt } : {}) })
160
+ // Typed first (OpenCode). providerFailureNeedsUser's vocabulary: quota, free-limit and budget-like types are quotas.
161
+ if (type === "provider.auth") return { kind: "auth" }
162
+ if (type === "provider.no-route") return { kind: "model_unavailable" }
163
+ if (type === "provider.transport" || type === "provider.timeout") return { kind: "network" }
164
+ if (type && type !== "provider.rate-limit" && type !== "provider.unknown" && providerFailureNeedsUser(type)) return quota()
165
+ if (harnessType(type)) {
166
+ const mapped = HARNESS_TYPES[type!]
167
+ if (mapped === undefined) return undefined
168
+ if (mapped !== "words") return mapped === "quota" ? quota() : { kind: mapped }
169
+ } else if (type && !type.startsWith("provider.")) return undefined // A tool's or harness's own error: never a provider class.
170
+ if (!marked) return CONNECTION.test(text) && !CONTENT_POLICY.test(text) ? { kind: "network" } : undefined
171
+ // Then the words (and the status) of the message.
172
+ if (QUOTA_WORDS.test(text)) return quota()
173
+ if ((type === "provider.rate-limit" || status === 429 || RATE_LIMIT.test(text)) && resetAt !== undefined && resetAt - now >= QUOTA_RESET_MS) return quota()
174
+ if (CONTEXT.test(text)) return { kind: "context_overflow" }
175
+ if (CONTENT_POLICY.test(text)) return undefined
176
+ if (MODEL.test(text) || (status === 404 && /\bmodel\b/i.test(text))) return { kind: "model_unavailable" }
177
+ if (status === 401 || status === 403 || AUTH.test(text)) return { kind: "auth" }
178
+ if (NETWORK.test(text)) return { kind: "network" }
179
+ return undefined
180
+ }
181
+
182
+ /**
183
+ * The session's health now. `retrySince`: when the current provider retry was first seen (the registry keeps it).
184
+ * A retry for a hard cause (quota, auth, model, context) is that cause at once: retrying will not fix it (decision
185
+ * 3kj1d70k: quota stalls on its first retry). Any other retry is retrying, then stalled after STALL_ATTEMPTS attempts
186
+ * or STALL_MS. A failed run is its cause, else failed.
187
+ */
188
+ export function classifyHealth(input: HealthInput, context: { readonly now: number; readonly retrySince?: number }): SessionHealth {
189
+ const { now } = context
190
+ const retry = input.state === "working" ? input.retry : undefined
191
+ if (retry) {
192
+ const attempt = typeof retry.attempt === "number" && retry.attempt > 0 ? { attempt: retry.attempt } : {}
193
+ // A provider retry is itself the marker: only a provider request is retried this way.
194
+ const found = cause(retry.errorType, retry.errorStatus, retry.message, now, true)
195
+ const detail = found?.kind === "auth" ? undefined : sanitizeHealthDetail(retry.message)
196
+ const base = { ...attempt, ...(detail ? { detail } : {}) }
197
+ if (found && found.kind !== "network") return { kind: found.kind, ...(found.resetAt !== undefined ? { resetAt: found.resetAt } : {}), ...base }
198
+ const stalled = (retry.attempt ?? 0) >= STALL_ATTEMPTS || (context.retrySince !== undefined && now - context.retrySince >= STALL_MS)
199
+ return { kind: stalled ? "stalled" : "retrying", ...base }
200
+ }
201
+ if (input.state === "error") {
202
+ const marked = (input.errorType?.startsWith("provider.") ?? false) || harnessType(input.errorType) || input.errorStatus !== undefined
203
+ const found = cause(input.errorType, input.errorStatus, input.detail, now, marked)
204
+ // An auth failure keeps no text at all: the kind says enough, and its message is the likeliest to echo a key.
205
+ const detail = found?.kind === "auth" ? undefined : sanitizeHealthDetail(input.detail)
206
+ return { kind: found?.kind ?? "failed", ...(found?.resetAt !== undefined ? { resetAt: found.resetAt } : {}), ...(detail ? { detail } : {}) }
207
+ }
208
+ return { kind: "ok" }
209
+ }