@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
@@ -19,6 +19,7 @@ import { DoneWatch } from "./done"
19
19
  import { FrontRelays, RelayRefused } from "./front-relays"
20
20
  import { SentenceStream } from "./say-stream"
21
21
  import type { ActivityStep, NotificationKind } from "./push"
22
+ import { classifyHealth, type SessionHealth } from "./health/classify"
22
23
 
23
24
  export { ACTIVITY_INTERVAL_MINUTES } from "./store"
24
25
  /** A tool call that took at least this long is named in activity updates (shorter ones are only counted). */
@@ -54,8 +55,12 @@ export interface MachineRef {
54
55
  }
55
56
 
56
57
  /** A status tree entry as the hub reports it: working sessions carry when their current run started. */
57
- /** `since`: a working entry's run start; `blockedSince`: when a blocked entry's oldest open blocker was first seen. */
58
- export type LiveStatus = SessionStatus & { readonly since?: number; readonly blockedSince?: number }
58
+ /**
59
+ * `since`: a working entry's run start; `blockedSince`: when a blocked entry's oldest open blocker was first seen;
60
+ * (SS-1) `health`: why the session is not making progress (absent: ok). The state itself is unchanged (a retrying
61
+ * session stays "working").
62
+ */
63
+ export type LiveStatus = SessionStatus & { readonly since?: number; readonly blockedSince?: number; readonly health?: SessionHealth }
59
64
 
60
65
  /** Timer functions (injectable for tests). */
61
66
  export interface Timers {
@@ -590,6 +595,9 @@ export class Registry {
590
595
  private readonly since = new Map<string, number>()
591
596
  /** (Session findability) Blocker ID → when this process first saw it (the "waiting 12m" of Needs you). */
592
597
  private readonly blockerSeen = new Map<string, number>()
598
+ /** (SS-1) When each session's current provider retry was first seen, and its health kind with when it began. */
599
+ private readonly retrySeen = new Map<string, number>()
600
+ private readonly healthSeen = new Map<string, { kind: SessionHealth["kind"]; since: number; detail?: string; resetAt?: number }>()
593
601
  /** Session → streamed text waiting for its send window. */
594
602
  private readonly streams = new Map<string, Stream>()
595
603
  /** Machine → its latest terminals frame and the connection that sent it. */
@@ -1645,10 +1653,33 @@ export class Registry {
1645
1653
  })
1646
1654
  : s.blockers
1647
1655
  const entry: LiveStatus = recommended ? { ...s, blockers } : s
1648
- return since === undefined && blockedSince === undefined ? entry : { ...entry, ...(since !== undefined ? { since } : {}), ...(blockedSince !== undefined ? { blockedSince } : {}) }
1656
+ const health = this.health(s)
1657
+ return since === undefined && blockedSince === undefined && health === undefined ? entry : { ...entry, ...(since !== undefined ? { since } : {}), ...(blockedSince !== undefined ? { blockedSince } : {}), ...(health ? { health } : {}) }
1649
1658
  })
1650
1659
  }
1651
1660
 
1661
+ /** (SS-1) The entry's health with the time its kind began; undefined while ok (the history is then forgotten). */
1662
+ private health(s: SessionStatus): SessionHealth | undefined {
1663
+ const now = this.now()
1664
+ if (s.retry && s.state === "working") {
1665
+ if (!this.retrySeen.has(s.id)) remember(this.retrySeen, s.id, now, BLOCKERS_SEEN)
1666
+ } else this.retrySeen.delete(s.id)
1667
+ const health = classifyHealth(s, { now, ...(this.retrySeen.has(s.id) ? { retrySince: this.retrySeen.get(s.id)! } : {}) })
1668
+ if (health.kind === "ok") {
1669
+ this.healthSeen.delete(s.id)
1670
+ return undefined
1671
+ }
1672
+ let seen = this.healthSeen.get(s.id)
1673
+ if (seen?.kind !== health.kind) remember(this.healthSeen, s.id, (seen = { kind: health.kind, since: now }), BLOCKERS_SEEN)
1674
+ // A relative hint ("reset after 118h") is anchored when its message is first seen: re-parsing it at every read
1675
+ // would move resetAt forward each time (and journal a change for every status update).
1676
+ if (health.resetAt !== undefined) {
1677
+ if (seen!.detail !== health.detail || seen!.resetAt === undefined) Object.assign(seen!, { detail: health.detail, resetAt: health.resetAt })
1678
+ return { ...health, resetAt: seen!.resetAt, since: seen!.since }
1679
+ }
1680
+ return { ...health, since: seen!.since }
1681
+ }
1682
+
1652
1683
  catalog(): CatalogMachine[] {
1653
1684
  const machines = new Map<string, { id: string; name: string; processes: CatalogProcess[] }>()
1654
1685
  for (const conn of this.all().sort((a, b) => a.seq - b.seq)) {
@@ -0,0 +1,144 @@
1
+ // Per-team intake wake usage. An item pauses alone when its wakes or running hours are exhausted.
2
+ import type { StateStore } from "../backend/types"
3
+
4
+ const COLLECTION = "team-item-budget"
5
+ const CONTEXT = "team-item-context"
6
+ const EXHAUSTION = "team-item-exhaustion"
7
+ const DAY_MS = 24 * 60 * 60_000
8
+
9
+ export interface ItemBudgetLimits { readonly wakes: number; readonly hours: number }
10
+ export interface ItemBudgetUsage { readonly wakes: number; readonly hours: number; readonly budget: ItemBudgetLimits; readonly generation: number }
11
+ type Usage = { readonly wakes: readonly number[]; readonly generation?: number; readonly startedAt?: number; readonly extendedBy?: readonly string[]; readonly charged?: Readonly<Record<string, number>> }
12
+ /** Sessions charged a wake per item (TA2-8 resets only these); oldest dropped beyond this. */
13
+ const CHARGED_MAX = 32
14
+ type ChildUsage = { readonly wakes: number; readonly chargedTo: string | "pool"; readonly at: number }
15
+
16
+ export class ItemBudgets {
17
+ constructor(private readonly deps: {
18
+ readonly state: StateStore
19
+ readonly limits: (userID: string, teamID: string) => Promise<ItemBudgetLimits> | ItemBudgetLimits
20
+ readonly now?: () => number
21
+ readonly exhausted?: (userID: string, teamID: string, itemID: string | undefined, reason: "wakes" | "hours", limit: number, usage: ItemBudgetUsage) => Promise<void>
22
+ }) {}
23
+
24
+ private now(): number { return (this.deps.now ?? Date.now)() }
25
+ private key(teamID: string, itemID: string | undefined): string { return `${teamID}:${itemID ?? "unattributed"}` }
26
+ private active(row: Usage | undefined, now: number): number[] { return (row?.wakes ?? []).filter((at) => at > now - DAY_MS) }
27
+
28
+ private static charge(previous: Usage["charged"], sessions: readonly string[], now: number): Record<string, number> | undefined {
29
+ if (!sessions.length) return previous ? { ...previous } : undefined
30
+ const next: Record<string, number> = { ...previous }
31
+ for (const sessionID of sessions) { delete next[sessionID]; next[sessionID] = now }
32
+ const kept = Object.entries(next).sort((a, b) => b[1] - a[1]).slice(0, CHARGED_MAX)
33
+ return Object.fromEntries(kept)
34
+ }
35
+
36
+ /** Sessions charged a wake for this item within the last `windowMs` (newest first). */
37
+ async charged(userID: string, teamID: string, itemID: string, windowMs: number): Promise<string[]> {
38
+ const row = (await this.deps.state.get<Usage>(userID, COLLECTION, this.key(teamID, itemID)))?.value
39
+ const since = this.now() - windowMs
40
+ return Object.entries(row?.charged ?? {}).filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([sessionID]) => sessionID)
41
+ }
42
+
43
+ private async report(userID: string, teamID: string, itemID: string | undefined, reason: "wakes" | "hours", limit: number, usage: ItemBudgetUsage, now: number): Promise<void> {
44
+ const key = `${this.key(teamID, itemID)}:${reason}:${itemID ? usage.generation : "pool"}`
45
+ const previous = await this.deps.state.get<{ at: number }>(userID, EXHAUSTION, key)
46
+ if (!itemID && previous && now - previous.value.at >= DAY_MS) await this.deps.state.delete(userID, EXHAUSTION, key, { ifVersion: previous.version })
47
+ const claimed = await this.deps.state.put(userID, EXHAUSTION, key, { at: now }, { ifVersion: 0 })
48
+ if (claimed.ok) try { await this.deps.exhausted?.(userID, teamID, itemID, reason, limit, usage) }
49
+ catch (cause) { console.error("[hub] item budget exhaustion notice failed:", cause) }
50
+ }
51
+
52
+ /** Last item assigned or received in this team; validity is rechecked before using it. */
53
+ async current(userID: string, teamID: string, sessionID: string): Promise<string | undefined> {
54
+ return (await this.deps.state.get<Record<string, { item: string; at: number }>>(userID, CONTEXT, sessionID))?.value[teamID]?.item
55
+ }
56
+
57
+ /** One row per session, not a scan of every team/session context. Eligibility is rechecked before inheritance. */
58
+ async currents(userID: string, sessionID: string): Promise<readonly { teamID: string; item: string }[]> {
59
+ const row = (await this.deps.state.get<Record<string, { item: string; at: number }>>(userID, CONTEXT, sessionID))?.value ?? {}
60
+ return Object.entries(row)
61
+ .sort(([teamA, a], [teamB, b]) => b.at - a.at || teamA.localeCompare(teamB))
62
+ .map(([teamID, value]) => ({ teamID, item: value.item }))
63
+ }
64
+
65
+ async setCurrent(userID: string, teamID: string, sessionID: string, item: string): Promise<void> {
66
+ for (let attempt = 0; attempt < 64; attempt++) {
67
+ const current = await this.deps.state.get<Record<string, { item: string; at: number }>>(userID, CONTEXT, sessionID)
68
+ const result = await this.deps.state.put(userID, CONTEXT, sessionID, { ...current?.value, [teamID]: { item, at: this.now() } }, { ifVersion: current?.version ?? 0 })
69
+ if (result.ok) return
70
+ await new Promise<void>((resolve) => setTimeout(resolve, 1 + Math.floor(Math.random() * 10)))
71
+ }
72
+ throw new Error("Item context write contention")
73
+ }
74
+
75
+ async chargeChild(userID: string, teamID: string, itemID: string, chargedTo: string | "pool"): Promise<void> {
76
+ const key = `${teamID}:${itemID}`
77
+ for (let i = 0; i < 64; i++) {
78
+ const current = await this.deps.state.get<ChildUsage>(userID, "team-item-child-usage", key)
79
+ const next = await this.deps.state.put(userID, "team-item-child-usage", key, { wakes: (current?.value.wakes ?? 0) + 1, chargedTo, at: this.now() }, { ifVersion: current?.version ?? 0 })
80
+ if (next.ok) return
81
+ await new Promise<void>((resolve) => setTimeout(resolve, 1 + Math.floor(Math.random() * 10)))
82
+ }
83
+ console.error("[hub] item child usage contention; count may lag")
84
+ }
85
+
86
+ async usage(userID: string, teamID: string, itemID: string, startedAt?: number, parentItem?: string): Promise<ItemBudgetUsage & { readonly chargedTo?: string }> {
87
+ const now = this.now()
88
+ const limits = await this.deps.limits(userID, teamID)
89
+ const row = (await this.deps.state.get<Usage>(userID, COLLECTION, this.key(teamID, itemID)))?.value
90
+ const child = parentItem ? (await this.deps.state.get<ChildUsage>(userID, "team-item-child-usage", `${teamID}:${itemID}`))?.value : undefined
91
+ const generation = row?.generation ?? 1
92
+ return { wakes: parentItem ? child?.wakes ?? 0 : row?.wakes.length ?? 0, hours: (row?.startedAt ?? startedAt) === undefined ? 0 : Math.max(0, (now - (row?.startedAt ?? startedAt)!) / 3_600_000), budget: { wakes: limits.wakes * generation, hours: limits.hours * generation }, generation, ...(parentItem ? { chargedTo: child?.chargedTo ?? parentItem } : {}) }
93
+ }
94
+
95
+ /** Extend is cumulative: the allowance grows, but the prior wake/hour usage remains true. */
96
+ async extend(userID: string, teamID: string, itemID: string, ref: string): Promise<ItemBudgetLimits> {
97
+ const key = this.key(teamID, itemID)
98
+ for (let i = 0; i < 32; i++) {
99
+ const current = await this.deps.state.get<Usage>(userID, COLLECTION, key)
100
+ const prior = current?.value
101
+ if (prior?.extendedBy?.includes(ref)) {
102
+ const base = await this.deps.limits(userID, teamID)
103
+ const generation = prior.generation ?? 1
104
+ return { wakes: base.wakes * generation, hours: base.hours * generation }
105
+ }
106
+ const generation = (prior?.generation ?? 1) + 1
107
+ const result = await this.deps.state.put(userID, COLLECTION, key, { wakes: prior?.wakes ?? [], generation, extendedBy: [...(prior?.extendedBy ?? []), ref], ...(prior?.startedAt !== undefined ? { startedAt: prior.startedAt } : {}), ...(prior?.charged ? { charged: prior.charged } : {}) }, { ifVersion: current?.version ?? 0 })
108
+ if (result.ok) { const base = await this.deps.limits(userID, teamID); return { wakes: base.wakes * generation, hours: base.hours * generation } }
109
+ }
110
+ throw new Error("Item budget extend contention")
111
+ }
112
+
113
+ /** Reserve a wake under CAS; false means this item (or its team's unattributed pool) is exhausted. */
114
+ /** `sessions`: the sender and recipient this wake is charged for (recorded on the item, never the pool). */
115
+ async wake(userID: string, teamID: string, itemID?: string, startedAt?: number, sessions: readonly string[] = []): Promise<{ allowed: boolean; limit: number; reason?: "wakes" | "hours" }> {
116
+ const limits = await this.deps.limits(userID, teamID)
117
+ const poolLimit = Math.floor(limits.wakes / 5)
118
+ const now = this.now()
119
+ const key = this.key(teamID, itemID)
120
+ for (let i = 0; i < 64; i++) {
121
+ const current = await this.deps.state.get<Usage>(userID, COLLECTION, key)
122
+ const generation = itemID ? (current?.value.generation ?? 1) : 1
123
+ const limit = itemID ? limits.wakes * generation : poolLimit
124
+ const active = itemID ? [...(current?.value.wakes ?? [])] : this.active(current?.value, now)
125
+ const start = current?.value.startedAt ?? startedAt ?? (itemID ? now : undefined)
126
+ if (itemID && start !== undefined && now - start >= limits.hours * generation * 3_600_000) {
127
+ const usage: ItemBudgetUsage = { wakes: active.length, hours: (now - start) / 3_600_000, budget: { wakes: limit, hours: limits.hours * generation }, generation }
128
+ await this.report(userID, teamID, itemID, "hours", usage.budget.hours, usage, now)
129
+ return { allowed: false, limit: usage.budget.hours, reason: "hours" }
130
+ }
131
+ if (active.length >= limit) {
132
+ await this.report(userID, teamID, itemID, "wakes", limit, { wakes: active.length, hours: start === undefined ? 0 : Math.max(0, (now - start) / 3_600_000), budget: { wakes: limit, hours: limits.hours * generation }, generation }, now)
133
+ return { allowed: false, limit, reason: "wakes" }
134
+ }
135
+ const result = await this.deps.state.put(userID, COLLECTION, key, { wakes: [...active, now], generation, ...(current?.value.extendedBy ? { extendedBy: current.value.extendedBy } : {}), ...(start !== undefined ? { startedAt: start } : {}), ...(itemID && (sessions.length || current?.value.charged) ? { charged: ItemBudgets.charge(current?.value.charged, sessions, now) } : {}) }, { ifVersion: current?.version ?? 0 })
136
+ if (result.ok) return { allowed: true, limit }
137
+ // Avoid a lockstep CAS stampede between sessions on the same item.
138
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.min(20, 1 + Math.floor(Math.random() * (i + 1)))))
139
+ }
140
+ // A message may already be stored: fail closed (no wake), never turn contention into a send error.
141
+ console.error("[hub] item budget contention; skipping wake")
142
+ return { allowed: false, limit: itemID ? limits.wakes : poolLimit, reason: "wakes" }
143
+ }
144
+ }
@@ -17,11 +17,9 @@ interface Stall {
17
17
  export class TeamHopStalls {
18
18
  constructor(private readonly deps: {
19
19
  readonly state: StateStore
20
- readonly seats: (userID: string, teamID: string) => Promise<readonly string[]>
21
20
  readonly open: (userID: string, teamID: string, sender: string, recipient: string, limit: number) => Promise<string>
22
21
  readonly notice?: (userID: string, teamID: string, decisionID: string) => Promise<void>
23
- readonly close?: (userID: string, decisionID: string) => Promise<void>
24
- readonly reset: (userID: string, sessionID: string) => Promise<void>
22
+ readonly close?: (userID: string, decisionID: string, reason?: string) => Promise<void>
25
23
  readonly now?: () => number
26
24
  }) {}
27
25
 
@@ -55,11 +53,21 @@ export class TeamHopStalls {
55
53
  return stall as Stall & { readonly decisionID: string }
56
54
  }
57
55
 
58
- async renew(userID: string, teamID: string, decisionID: string): Promise<void> {
56
+ async matches(userID: string, teamID: string, decisionID: string): Promise<void> {
59
57
  const entry = await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID)
60
58
  if (!entry || entry.value.decisionID !== decisionID) throw new Error("No matching team hop-cap Decision")
61
- for (const sessionID of new Set(await this.deps.seats(userID, teamID))) await this.deps.reset(userID, sessionID)
62
- await this.deps.state.delete(userID, TEAM_HOP_STALLS, teamID, { ifVersion: entry.version })
59
+ }
60
+
61
+ /** A user has taken part in this team again: clear its stall and close the pending Renew Decision. */
62
+ /** Whether the team has an open hop-cap Decision. */
63
+ async open(userID: string, teamID: string): Promise<boolean> {
64
+ return (await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID))?.value.decisionID !== undefined
65
+ }
66
+
67
+ async clearTeam(userID: string, teamID: string, reason = "The user is involved again"): Promise<void> {
68
+ const entry = await this.deps.state.get<Stall>(userID, TEAM_HOP_STALLS, teamID)
69
+ if (!entry || !(await this.deps.state.delete(userID, TEAM_HOP_STALLS, teamID, { ifVersion: entry.version })).ok) return
70
+ if (entry.value.decisionID) await this.deps.close?.(userID, entry.value.decisionID, reason)
63
71
  }
64
72
 
65
73
  async clearForHuman(userID: string, sessionID: string): Promise<void> {
@@ -83,6 +83,7 @@ import type { Clock, StateStore, Timers } from "../backend/types"
83
83
  import { assignmentChangedEvent } from "../workspaces/events"
84
84
  import type { MembershipStore, SessionPresence, SessionPresenceLookup, StoredAssignment } from "../workspaces/members"
85
85
  import type { TeamSeatStore } from "./seats"
86
+ import type { ItemBudgets, ItemBudgetUsage } from "./budgets"
86
87
  import type { TeamStaffingService } from "./staffing"
87
88
  import type { Team, TeamStore } from "./store"
88
89
 
@@ -139,6 +140,9 @@ export const INTAKE_LIMITS: IntakeLimits = {
139
140
 
140
141
  /** A TeamIntake as stored and read: the contract record plus additive fields (HWA-36). */
141
142
  export interface StoredIntake extends TeamIntake {
143
+ /** Session-submitted child: its root item, when eligible at submit time. Old rows omit it. */
144
+ readonly parentItem?: string
145
+ readonly usage?: ItemBudgetUsage
142
146
  readonly priority: IntakePriority
143
147
  readonly labels?: readonly string[]
144
148
  /** Due time (epoch ms). */
@@ -158,6 +162,8 @@ export interface StoredIntake extends TeamIntake {
158
162
  readonly lastError?: string
159
163
  readonly updatedAt: number
160
164
  readonly routedAt?: number
165
+ /** First active work (routed or in_progress); pauses and blocked time continue counting. */
166
+ readonly startedAt?: number
161
167
  readonly closedAt?: number
162
168
  }
163
169
 
@@ -206,6 +212,15 @@ export interface TeamIntakeDeps {
206
212
  readonly deliver: IntakeDeliver
207
213
  /** A session submitter's hop chain (absent: deliveries start their own chain). */
208
214
  readonly hopOf?: IntakeHopOf
215
+ /** Called only for a new, persisted intake submitted by the authenticated user. */
216
+ readonly submittedByUser?: (userID: string, teamID: string, intakeID: string, actor: Extract<AgentActor, { kind: "user" }>) => Promise<void>
217
+ readonly budgets?: Pick<ItemBudgets, "usage">
218
+ /** The entry seat received an item after routing; remember its current work context. */
219
+ readonly routed?: (userID: string, teamID: string, intakeID: string, sessionID: string) => Promise<void>
220
+ /** Resolve a session submitter's eligible current root item; undefined charges its child to the team pool. */
221
+ readonly parentOf?: (userID: string, teamID: string, sessionID: string) => Promise<string | undefined>
222
+ /** A real status/revision change of a routed item; attribution uses the assigned seat, not the reporter's words. */
223
+ readonly progressChanged?: (input: { readonly userID: string; readonly teamID: string; readonly item: string; readonly sessionID?: string; readonly revision: number; readonly status: string; readonly actor: AgentActor }) => void | Promise<void>
209
224
  readonly events?: Pick<AgentEvents, "append">
210
225
  readonly report?: (cause: unknown) => void
211
226
  readonly now?: Clock
@@ -335,6 +350,12 @@ interface Target {
335
350
  }
336
351
 
337
352
  export class TeamIntakeService {
353
+ private async withUsage(userID: string, row: IntakeRow): Promise<StoredIntake> {
354
+ const item = view(row)
355
+ if (!this.deps.budgets) return item
356
+ try { return { ...item, usage: await this.deps.budgets.usage(userID, row.teamID, row.id, row.startedAt, row.source.kind === "session" ? row.parentItem ?? "pool" : undefined) } }
357
+ catch (cause) { this.report(cause); return item }
358
+ }
338
359
  private readonly state: StateStore
339
360
  private readonly now: Clock
340
361
  private readonly newID: (kind: IdKind) => string
@@ -376,6 +397,7 @@ export class TeamIntakeService {
376
397
  const slot = key === undefined ? undefined : `${partyOf(actor)}|${key}`
377
398
  const id = this.newID("intake")
378
399
  const hop = actor.kind === "session" && this.deps.hopOf ? await this.deps.hopOf(userID, actor.sessionID) : undefined
400
+ const parentItem = actor.kind === "session" ? await this.deps.parentOf?.(userID, teamID, actor.sessionID) : undefined
379
401
  if (body.brief !== undefined && !expired) await this.state.put(userID, TEAM_INTAKE_BRIEFS, id, { v: 1, teamID, brief: body.brief })
380
402
  let result: { row: IntakeRow; created: boolean } | undefined
381
403
  try {
@@ -401,7 +423,7 @@ export class TeamIntakeService {
401
423
  if (doc.items.filter((i) => !CLOSED.has(i.status)).length >= this.limits.openPerTeam)
402
424
  throw new AgentError("rate_limited", `A team holds at most ${this.limits.openPerTeam} open submissions`, { hint: "Close or cancel some first", extra: { limit: this.limits.openPerTeam } })
403
425
  const { brief: _brief, ...fields } = body
404
- const row: IntakeRow = { v: 1, id, teamID, ...fields, source: sourceOf(actor), status: "queued", revision: 1, createdAt: at, updatedAt: at, ...(hop === undefined ? {} : { hop }) }
426
+ const row: IntakeRow = { v: 1, id, teamID, ...fields, source: sourceOf(actor), status: "queued", revision: 1, createdAt: at, updatedAt: at, ...(hop === undefined ? {} : { hop }), ...(parentItem ? { parentItem } : {}) }
405
427
  result = { row, created: true }
406
428
  const nextKeys = slot === undefined ? keys : { ...keys, [slot]: { intakeID: id, digest, at } }
407
429
  return { ...doc, items: this.retained([...doc.items, row]), keys: this.boundedKeys(nextKeys) }
@@ -416,6 +438,10 @@ export class TeamIntakeService {
416
438
  return { intake: view(await this.reconciled(userID, (await this.row(userID, teamID, result!.row.id)) ?? result!.row)), created: false }
417
439
  }
418
440
  await this.emit(userID, intakeEvent(result!.row))
441
+ if (actor.kind === "user") {
442
+ try { await this.deps.submittedByUser?.(userID, teamID, id, actor) }
443
+ catch (cause) { this.report(cause) }
444
+ }
419
445
  if (result!.row.notAfter !== undefined) this.arm(result!.row.notAfter)
420
446
  await this.drainSafely(userID, teamID)
421
447
  return { intake: view((await this.row(userID, teamID, id)) ?? result!.row), created: true }
@@ -429,7 +455,8 @@ export class TeamIntakeService {
429
455
  await this.drainSafely(userID, teamID)
430
456
  const row = await this.row(userID, teamID, intakeID)
431
457
  if (!row || (scope === "own" && !sameSource(row, actor))) throw new AgentError("not_found", `No intake ${intakeID} in team ${teamID}`)
432
- return view(await this.reconciled(userID, row))
458
+ const current = await this.reconciled(userID, row)
459
+ return this.withUsage(userID, current)
433
460
  }
434
461
 
435
462
  /** The team's intake the caller may see, oldest first, paged. */
@@ -448,7 +475,7 @@ export class TeamIntakeService {
448
475
  .filter((i) => query.status === undefined || i.status === query.status)
449
476
  .filter((i) => !after || i.createdAt > after[0] || (i.createdAt === after[0] && i.id > after[1]))
450
477
  .sort(byCreation)
451
- const items = all.slice(0, limit).map(view)
478
+ const items = await Promise.all(all.slice(0, limit).map((row) => this.withUsage(userID, row)))
452
479
  return all.length > limit ? { items, cursor: encodeCursor(items.at(-1)!) } : { items }
453
480
  }
454
481
 
@@ -517,9 +544,13 @@ export class TeamIntakeService {
517
544
  const { progress: _p, claim: _c, ...rest } = current
518
545
  if (closing) return { ...rest, status: wanted, ...(reason !== undefined ? { reason: reason as string } : {}), closedAt: this.now() }
519
546
  if (current.progress === wanted) return undefined
520
- return { ...rest, ...(current.claim ? { claim: current.claim } : {}), progress: wanted as IntakeProgress }
547
+ return { ...rest, ...(current.claim ? { claim: current.claim } : {}), progress: wanted as IntakeProgress, ...(wanted === "in_progress" ? { startedAt: current.startedAt ?? this.now() } : {}) }
521
548
  })
522
- if (next.revision !== row.revision) await this.emit(userID, intakeEvent(next))
549
+ if (next.revision !== row.revision) {
550
+ await this.emit(userID, intakeEvent(next))
551
+ try { await this.deps.progressChanged?.({ userID, teamID, item: intakeID, ...(next.sessionID ? { sessionID: next.sessionID } : {}), revision: next.revision, status: next.progress ?? next.status, actor }) }
552
+ catch (cause) { this.report(cause) }
553
+ }
523
554
  if (closing && row.status === "queued") await this.state.delete(userID, TEAM_INTAKE_BRIEFS, intakeID).catch(this.report)
524
555
  return { intake: view(next), ...(assignment ? { assignment } : {}) }
525
556
  }
@@ -738,7 +769,7 @@ export class TeamIntakeService {
738
769
  return claim ? { ...doc, items: doc.items.map((i) => (i.id === intakeID ? rest : i)) } : undefined
739
770
  }
740
771
  const t = this.now()
741
- routed = { ...rest, status: "routed", progress: "open", assignmentID: assignment.id, workspaceID: target.workspaceID, sessionID: target.sessionID, messageID: sent.messageID, routedAt: t, revision: row.revision + 1, updatedAt: t }
772
+ routed = { ...rest, status: "routed", progress: "open", assignmentID: assignment.id, workspaceID: target.workspaceID, sessionID: target.sessionID, messageID: sent.messageID, routedAt: t, startedAt: row.startedAt ?? t, revision: row.revision + 1, updatedAt: t }
742
773
  return { ...doc, items: doc.items.map((i) => (i.id === intakeID ? routed! : i)) }
743
774
  })
744
775
  if (cancelled) {
@@ -747,6 +778,7 @@ export class TeamIntakeService {
747
778
  }
748
779
  await this.state.delete(userID, TEAM_INTAKE_BRIEFS, intakeID).catch(this.report)
749
780
  await this.emit(userID, intakeEvent(routed!))
781
+ try { await this.deps.routed?.(userID, team.id, routed!.id, target.sessionID) } catch (cause) { this.report(cause) }
750
782
  return "routed"
751
783
  } catch (cause) {
752
784
  const code = cause instanceof AgentError ? cause.code : "internal"
@@ -0,0 +1,95 @@
1
+ // A user act toward one team (or a current seat of several teams) resets the team's agent-only hop chain.
2
+ import type { TeamHopStalls } from "./hop-stalls"
3
+
4
+ export type InvolvementAct = {
5
+ readonly kind: "decision" | "intake" | "permission" | "form" | "prompt" | "renew" | "manual" | "budget" | "progress"
6
+ readonly teamIDs?: readonly string[]
7
+ readonly sessionID?: string
8
+ readonly ref: string
9
+ readonly actor?: { readonly deviceID?: string; readonly via?: string }
10
+ }
11
+
12
+ export interface TeamResetRecord {
13
+ readonly userID: string
14
+ readonly teamID: string
15
+ readonly kind: InvolvementAct["kind"]
16
+ readonly ref: string
17
+ readonly seats: readonly string[]
18
+ readonly failed: readonly string[]
19
+ readonly actor?: InvolvementAct["actor"]
20
+ }
21
+
22
+ export interface TeamInvolvementDeps {
23
+ /** Current seats only: a departed member must never be reset. */
24
+ readonly seats: (userID: string, teamID: string) => Promise<readonly string[]>
25
+ readonly seatsOf: (userID: string, sessionID: string) => Promise<readonly { readonly teamID: string }[]>
26
+ readonly reset: (userID: string, sessionID: string) => Promise<void>
27
+ readonly stalls: Pick<TeamHopStalls, "clearTeam" | "matches">
28
+ readonly audit: (entry: TeamResetRecord) => Promise<void>
29
+ readonly event: (entry: TeamResetRecord) => Promise<void>
30
+ }
31
+
32
+ export class TeamInvolvement {
33
+ constructor(private readonly deps: TeamInvolvementDeps) {}
34
+
35
+ /** Renew must fail if lookup or reset fails, leaving its Decision open for a retry. */
36
+ async renew(userID: string, teamID: string, decisionID: string, actor?: InvolvementAct["actor"]): Promise<void> {
37
+ await this.deps.stalls.matches(userID, teamID, decisionID)
38
+ await this.resetTeam(userID, teamID, { kind: "renew", teamIDs: [teamID], ref: decisionID, ...(actor ? { actor } : {}) }, true)
39
+ }
40
+
41
+ /** The user's Renew button in the console: strict like renew, but needs no stall (a failure → the caller's 500, retryable). */
42
+ async renewManual(userID: string, teamID: string, actor: NonNullable<InvolvementAct["actor"]>): Promise<void> {
43
+ await this.resetTeam(userID, teamID, { kind: "manual", teamIDs: [teamID], ref: "console", actor }, true)
44
+ }
45
+
46
+ /** Best effort: never make the user's original action fail because one reset/audit/event did. */
47
+ async involved(userID: string, act: InvolvementAct): Promise<void> {
48
+ try {
49
+ const teams = new Set(act.teamIDs ?? [])
50
+ if (act.sessionID) for (const seat of await this.deps.seatsOf(userID, act.sessionID)) teams.add(seat.teamID)
51
+ for (const teamID of teams) {
52
+ try { await this.resetTeam(userID, teamID, act, false) }
53
+ catch (cause) { console.error(`[hub] team involvement for ${teamID} failed:`, cause) }
54
+ }
55
+ } catch (cause) { console.error("[hub] team involvement lookup failed:", cause) }
56
+ }
57
+
58
+ /** A qualifying hub-observed progress signal (TA2-5) resets only seats charged to this item; never all team seats. */
59
+ async progress(userID: string, teamID: string, item: string, charged: readonly string[]): Promise<void> {
60
+ const seats = [...new Set(charged)]
61
+ if (seats.length === 0) return
62
+ const failed: string[] = []
63
+ for (const sessionID of seats) {
64
+ try { await this.deps.reset(userID, sessionID) }
65
+ catch (cause) { failed.push(sessionID); console.error(`[hub] item progress hop reset failed: ${cause instanceof Error ? cause.message : String(cause)}`) }
66
+ }
67
+ // Keep the team-level Renew stall and all seats outside the item's charged set unchanged.
68
+ const record: TeamResetRecord = { userID, teamID, kind: "progress", ref: item, seats, failed, actor: { via: "hub" } }
69
+ try { await this.deps.audit(record) }
70
+ catch (cause) { console.error(`[hub] item progress audit failed: ${cause instanceof Error ? cause.message : String(cause)}`) }
71
+ try { await this.deps.event(record) }
72
+ catch (cause) { console.error(`[hub] item progress event failed: ${cause instanceof Error ? cause.message : String(cause)}`) }
73
+ }
74
+
75
+ private async resetTeam(userID: string, teamID: string, act: InvolvementAct, strict: boolean): Promise<void> {
76
+ const seats = [...new Set(await this.deps.seats(userID, teamID))]
77
+ const failed: string[] = []
78
+ for (const sessionID of seats) {
79
+ try { await this.deps.reset(userID, sessionID) }
80
+ catch (cause) {
81
+ failed.push(sessionID)
82
+ console.error(`[hub] team hop reset for ${sessionID} failed:`, cause)
83
+ if (strict) throw cause
84
+ }
85
+ }
86
+ try { await this.deps.stalls.clearTeam(userID, teamID, act.kind === "manual" ? "The user renewed the team from the console" : undefined) }
87
+ catch (cause) { console.error(`[hub] team hop stall clear failed:`, cause); if (strict) throw cause }
88
+ const record: TeamResetRecord = { userID, teamID, kind: act.kind, ref: act.ref, seats, failed, ...(act.actor ? { actor: act.actor } : {}) }
89
+ // Renewal has already cleared its stall: audit/event failure must not reopen a Decision that cannot match.
90
+ try { await this.deps.audit(record) }
91
+ catch (cause) { console.error(`[hub] team hop reset audit failed:`, cause) }
92
+ try { await this.deps.event(record) }
93
+ catch (cause) { console.error(`[hub] team hop reset event failed:`, cause) }
94
+ }
95
+ }
@@ -0,0 +1,60 @@
1
+ // Pure ports for a tap-only item stall Decision. The hub wires these to budgets, loops, intake and involvement.
2
+ /** The value returned by ItemBudgets.extend once TA2-4 is in the parent branch. */
3
+ type ItemBudgetLimits = { readonly wakes: number; readonly hours: number }
4
+
5
+ export interface ItemDecisionStall {
6
+ readonly userID: string
7
+ readonly teamID: string
8
+ readonly item?: string
9
+ readonly title: string
10
+ readonly reason: "budget" | "loop"
11
+ readonly decisionID: string
12
+ readonly pair?: readonly [string, string]
13
+ readonly usage: { readonly wakes: number; readonly hours: number }
14
+ readonly limit: ItemBudgetLimits
15
+ }
16
+
17
+ export interface ItemDecisionActor { readonly kind: string; readonly userID: string; readonly deviceID?: string; readonly via?: string }
18
+
19
+ export function itemDecisionQuestion(stall: ItemDecisionStall): string | undefined {
20
+ if (!stall.item) return undefined // The team's unattributed pool receives a quiet notice, never a Decision.
21
+ const pair = stall.pair ? ` between ${stall.pair[0]} and ${stall.pair[1]}` : ""
22
+ const title = stall.title.replace(/[\x00-\x1f\x7f\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim().slice(0, 120).replace(/\\/g, "\\\\").replace(/"/g, '\\"')
23
+ const hours = (value: number) => (Math.round(value * 10) / 10).toFixed(1)
24
+ return `Item ${stall.item} ("${title}") ${stall.reason === "budget" ? "spent its budget" : `is looping${pair}`}. ${stall.usage.wakes}/${stall.limit.wakes} wakes, ${hours(stall.usage.hours)}/${hours(stall.limit.hours)} hours. Extend or Stop?`
25
+ }
26
+
27
+ export class ItemDecisions {
28
+ constructor(private readonly deps: {
29
+ readonly matches: (userID: string, teamID: string, itemID: string, decisionID: string) => Promise<boolean>
30
+ readonly clearStall: (userID: string, itemID: string, decisionID: string) => Promise<void>
31
+ readonly extend: (userID: string, teamID: string, itemID: string, decisionID: string) => Promise<ItemBudgetLimits>
32
+ readonly clearLoop: (userID: string, itemID: string, pair: readonly [string, string]) => Promise<void>
33
+ readonly involved: (userID: string, act: { kind: "budget"; teamIDs: string[]; ref: string; actor: { deviceID: string; via: "cookie" } }) => Promise<void>
34
+ readonly stop: (userID: string, teamID: string, itemID: string, reason: "budget" | "loop") => Promise<void>
35
+ readonly audit: (entry: { userID: string; actor: { kind: "user" }; deviceID: string; verb: "team.item.extend" | "team.item.stop"; target: {}; reason: string; decision: "allow"; outcome: "ok" }) => Promise<void>
36
+ }) {}
37
+
38
+ async answer(stall: ItemDecisionStall, option: string | undefined, by: ItemDecisionActor): Promise<"gone" | "done" | { outcome: "done"; note: string }> {
39
+ const { userID, teamID, item, decisionID, reason, pair } = stall
40
+ if (!item || (option !== "extend" && option !== "stop") || by.kind !== "user" || by.userID !== userID || by.via !== "cookie" || !by.deviceID) return "gone"
41
+ if (!(await this.deps.matches(userID, teamID, item, decisionID))) return "gone"
42
+ let note: string | undefined
43
+ if (option === "extend") {
44
+ if (reason === "budget") {
45
+ const limits = await this.deps.extend(userID, teamID, item, decisionID)
46
+ note = `Budget extended to ${limits.wakes} wakes / ${limits.hours} hours`
47
+ } else {
48
+ if (!pair) return "gone"
49
+ await this.deps.clearLoop(userID, item, pair)
50
+ }
51
+ try { await this.deps.involved(userID, { kind: "budget", teamIDs: [teamID], ref: decisionID, actor: { deviceID: by.deviceID, via: "cookie" } }) }
52
+ catch (cause) { console.error("[hub] item Decision involvement failed:", cause) }
53
+ } else await this.deps.stop(userID, teamID, item, reason)
54
+ try { await this.deps.audit({ userID, actor: { kind: "user" }, deviceID: by.deviceID, verb: option === "extend" ? "team.item.extend" : "team.item.stop", target: {}, reason: `${item} ${reason}${pair ? ` ${pair.join(" → ")}` : ""}: limit ${stall.limit.wakes}/${stall.limit.hours}`, decision: "allow", outcome: "ok" }) }
55
+ catch (cause) { console.error("[hub] item Decision audit failed:", cause) }
56
+ try { await this.deps.clearStall(userID, item, decisionID) }
57
+ catch (cause) { console.error("[hub] item Decision stall clear failed:", cause) }
58
+ return note ? { outcome: "done", note } : "done"
59
+ }
60
+ }
@@ -0,0 +1,35 @@
1
+ // One owner-side item check for explicit attribution and future inherited message context.
2
+ import type { TeamSeatStore } from "./seats"
3
+ import type { TeamStore } from "./store"
4
+ import type { TeamIntakeService } from "./intake"
5
+
6
+ export type ItemEligibility = { readonly ok: true; readonly teamID: string; readonly startedAt?: number; readonly parentItem?: string; readonly child?: boolean } | { readonly ok: false; readonly reason: "closed" | "archived" | "unseated"; readonly sessionID?: string; readonly team?: string }
7
+
8
+ export function itemEligibility(deps: {
9
+ readonly seats: Pick<TeamSeatStore, "seatsOf">
10
+ readonly teams: Pick<TeamStore, "get">
11
+ readonly intake: Pick<TeamIntakeService, "get">
12
+ }): { eligible(userID: string, item: string, sessionIDs: readonly string[]): Promise<ItemEligibility> } {
13
+ return {
14
+ async eligible(userID, itemID, sessionIDs) {
15
+ if (!sessionIDs.length) return { ok: false, reason: "unseated" }
16
+ const seats = await Promise.all(sessionIDs.map((id) => deps.seats.seatsOf(userID, id)))
17
+ for (const seat of seats[0] ?? []) {
18
+ const team = await deps.teams.get(userID, seat.teamID)
19
+ if (!team) continue
20
+ let item
21
+ try {
22
+ item = await deps.intake.get(userID, { kind: "user", userID, deviceID: "hub", via: "cookie" }, team.id, itemID)
23
+ } catch (cause) {
24
+ if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "not_found") continue
25
+ throw cause
26
+ }
27
+ if (team.state === "archived") return { ok: false, reason: "archived", team: team.name }
28
+ if (item.status === "done" || item.status === "failed" || item.status === "cancelled") return { ok: false, reason: "closed" }
29
+ for (let i = 1; i < sessionIDs.length; i++) if (!seats[i]?.some((s) => s.teamID === team.id)) return { ok: false, reason: "unseated", sessionID: sessionIDs[i] }
30
+ return { ok: true, teamID: team.id, ...(item.startedAt !== undefined ? { startedAt: item.startedAt } : {}), ...(item.parentItem ? { parentItem: item.parentItem } : {}), ...(item.source.kind === "session" ? { child: true } : {}) }
31
+ }
32
+ return { ok: false, reason: "unseated", sessionID: sessionIDs[0] }
33
+ },
34
+ }
35
+ }