@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,87 @@
1
+ // One persisted Extend/Stop Decision per intake item. CAS reservation prevents parallel budget/loop hits opening
2
+ // duplicate Decisions; TA2-7 wires the Decision action, quiet entry-seat notice, and budget/loop consumers.
3
+ import type { StateStore } from "../backend/types"
4
+ import { updateVersioned } from "../agent/versioned"
5
+
6
+ export const TEAM_ITEM_STALLS = "team-item-stalls"
7
+ const STALE_MS = 5 * 60_000
8
+ export type StallReason = "budget" | "loop"
9
+ export interface ItemStall { readonly teamID: string; readonly item: string; readonly reason: StallReason; readonly at: number; readonly decisionID?: string; readonly pair?: readonly [string, string]; readonly reopenedAt?: number }
10
+
11
+ export class ItemStalls {
12
+ constructor(private readonly deps: {
13
+ readonly state: StateStore
14
+ readonly open: (userID: string, teamID: string, item: string, reason: StallReason, pair?: readonly [string, string]) => Promise<string>
15
+ readonly notice?: (userID: string, teamID: string, item: string, decisionID: string) => Promise<void>
16
+ readonly now?: () => number
17
+ }) {}
18
+
19
+ private key(teamID: string, item: string): string { return `${teamID}:${item}` }
20
+
21
+ async hit(userID: string, teamID: string, item: string, reason: StallReason, pair?: readonly [string, string]): Promise<boolean> {
22
+ const key = this.key(teamID, item)
23
+ const now = (this.deps.now ?? Date.now)()
24
+ const previous = await this.deps.state.get<ItemStall>(userID, TEAM_ITEM_STALLS, key)
25
+ // A claim left by a crash or a failed open: replace it, but a re-open already spent stays spent.
26
+ const reopenedAt = previous?.value.reopenedAt
27
+ if (previous && previous.value.decisionID === undefined && now - previous.value.at >= STALE_MS)
28
+ await this.deps.state.delete(userID, TEAM_ITEM_STALLS, key, { ifVersion: previous.version })
29
+ const claim: ItemStall = { teamID, item, reason, at: now, ...(pair ? { pair } : {}), ...(reopenedAt !== undefined ? { reopenedAt } : {}) }
30
+ const claimed = await this.deps.state.put<ItemStall>(userID, TEAM_ITEM_STALLS, key, claim, { ifVersion: 0 })
31
+ if (!claimed.ok) return false
32
+ try {
33
+ await this.attach(userID, claim)
34
+ return true
35
+ } catch (cause) {
36
+ if (reopenedAt === undefined) await this.deps.state.delete(userID, TEAM_ITEM_STALLS, key, { ifVersion: claimed.version })
37
+ throw cause
38
+ }
39
+ }
40
+
41
+ /** Opens the claim's Decision, records it on the same claim (never another one), then the quiet notice. */
42
+ private async attach(userID: string, claim: ItemStall): Promise<string> {
43
+ const decisionID = await this.deps.open(userID, claim.teamID, claim.item, claim.reason, claim.pair)
44
+ await updateVersioned<ItemStall>(this.deps.state, userID, TEAM_ITEM_STALLS, this.key(claim.teamID, claim.item), (current) => current && current.decisionID === undefined && current.item === claim.item && current.at === claim.at ? { ...current, decisionID } : undefined)
45
+ try { await this.deps.notice?.(userID, claim.teamID, claim.item, decisionID) }
46
+ catch (cause) { console.error(`[hub] item stall notice failed: ${cause instanceof Error ? cause.message : String(cause)}`) }
47
+ return decisionID
48
+ }
49
+
50
+ /**
51
+ * The stall's Decision expired unanswered. The first time, ONE fresh Decision replaces it (same reason and pair);
52
+ * after that the item stays paused with the stall recorded, so an unattended item never yields a daily stream of
53
+ * cards. The stall is updated in place under CAS (never deleted), so a concurrent hit can't claim it without
54
+ * reopenedAt. "none": no stall, another Decision's, or a lost race.
55
+ */
56
+ async reopen(userID: string, teamID: string, item: string, expiredDecisionID: string): Promise<"none" | "reopened" | "kept"> {
57
+ const key = this.key(teamID, item)
58
+ const current = await this.deps.state.get<ItemStall>(userID, TEAM_ITEM_STALLS, key)
59
+ if (!current || current.value.decisionID !== expiredDecisionID) return "none"
60
+ if (current.value.reopenedAt !== undefined) return "kept"
61
+ const now = (this.deps.now ?? Date.now)()
62
+ const { decisionID: _expired, ...rest } = current.value
63
+ const claim: ItemStall = { ...rest, at: now, reopenedAt: now }
64
+ if (!(await this.deps.state.put<ItemStall>(userID, TEAM_ITEM_STALLS, key, claim, { ifVersion: current.version })).ok) return "none"
65
+ // A failed open leaves this claim (with reopenedAt): it goes stale and the next hit replaces it, keeping reopenedAt.
66
+ await this.attach(userID, claim)
67
+ return "reopened"
68
+ }
69
+
70
+ /** Clears the stall only while it still belongs to this Decision. */
71
+ async clearFor(userID: string, teamID: string, item: string, decisionID: string): Promise<boolean> {
72
+ const key = this.key(teamID, item)
73
+ const existing = await this.deps.state.get<ItemStall>(userID, TEAM_ITEM_STALLS, key)
74
+ if (!existing || existing.value.decisionID !== decisionID) return false
75
+ return (await this.deps.state.delete(userID, TEAM_ITEM_STALLS, key, { ifVersion: existing.version })).ok
76
+ }
77
+
78
+ async get(userID: string, teamID: string, item: string): Promise<ItemStall | undefined> {
79
+ return (await this.deps.state.get<ItemStall>(userID, TEAM_ITEM_STALLS, this.key(teamID, item)))?.value
80
+ }
81
+
82
+ async clear(userID: string, teamID: string, item: string): Promise<boolean> {
83
+ const key = this.key(teamID, item)
84
+ const existing = await this.deps.state.get<ItemStall>(userID, TEAM_ITEM_STALLS, key)
85
+ return existing ? (await this.deps.state.delete(userID, TEAM_ITEM_STALLS, key, { ifVersion: existing.version })).ok : false
86
+ }
87
+ }
@@ -0,0 +1,40 @@
1
+ // No-progress loop detection for one intake item and unordered pair of seated sessions. Consumer/wiring: TA2-7.
2
+ import type { ProgressSignal } from "./progress"
3
+
4
+ export interface LoopPair { readonly userID: string; readonly teamID: string; readonly item?: string; readonly from: string; readonly to: string }
5
+ export interface LoopThreshold { readonly messages: number; readonly windowMinutes: number }
6
+ interface Window { times: number[]; stopped: boolean; pair: readonly [string, string]; item?: string; teamID: string; userID: string }
7
+
8
+ export class LoopDetector {
9
+ private readonly windows = new Map<string, Window>()
10
+ private readonly now: () => number
11
+ constructor(private readonly deps: { readonly thresholds: (userID: string, teamID: string) => LoopThreshold; readonly now?: () => number }) { this.now = deps.now ?? Date.now }
12
+
13
+ private key({ userID, teamID, item, from, to }: LoopPair): string {
14
+ return JSON.stringify([userID, teamID, item ?? null, ...[from, to].sort()])
15
+ }
16
+
17
+ /** `threshold`: the effective team/hub value the caller resolved for this send (default: the injected reader). */
18
+ message(pair: LoopPair, threshold: LoopThreshold = this.deps.thresholds(pair.userID, pair.teamID)): boolean {
19
+ const key = this.key(pair)
20
+ const at = this.now()
21
+ const current = this.windows.get(key) ?? { times: [], stopped: false, pair: [pair.from, pair.to].sort() as [string, string], item: pair.item, teamID: pair.teamID, userID: pair.userID }
22
+ current.times = current.times.filter((time) => at - time < threshold.windowMinutes * 60_000)
23
+ current.times.push(at)
24
+ current.stopped = current.stopped || current.times.length >= threshold.messages
25
+ this.windows.set(key, current)
26
+ return current.stopped
27
+ }
28
+
29
+ stopped(pair: LoopPair): { reason: "loop" } | undefined { return this.windows.get(this.key(pair))?.stopped ? { reason: "loop" } : undefined }
30
+
31
+ progress(signal: ProgressSignal): void {
32
+ for (const [key, window] of this.windows) {
33
+ if (window.userID !== signal.userID || window.teamID !== signal.teamID) continue
34
+ if (signal.item !== undefined ? window.item !== signal.item : window.item !== undefined || !window.pair.includes(signal.sessionID)) continue
35
+ this.windows.delete(key)
36
+ }
37
+ }
38
+
39
+ clear(pair: LoopPair): void { this.windows.delete(this.key(pair)) }
40
+ }
@@ -0,0 +1,154 @@
1
+ // Hub-observed progress per item/seat. TA2-7 wires intakeChanged from TeamIntakeService.setStatus,
2
+ // artifactChanged from ArtifactStore's create/revise events (caller supplies revision author session), consoleAnswered from cookie-only Decision and
3
+ // permission/form answer paths, and DiffSampler.turnEnded from registry turn-end. No message-text input exists.
4
+ // Diff summaries are SAMPLED at turn end by an injected caller: the hub has no passive diff event stream.
5
+ import { createHash } from "node:crypto"
6
+ import type { SessionSummary } from "../../protocol"
7
+
8
+ export type ProgressKind = "intake" | "artifact" | "diff" | "decision" | "approval"
9
+ export interface ProgressSignal {
10
+ readonly userID: string
11
+ readonly teamID: string
12
+ readonly item?: string
13
+ readonly sessionID: string
14
+ readonly kind: ProgressKind
15
+ readonly at: number
16
+ }
17
+ export interface SeatContext { readonly teams: readonly { readonly teamID: string; readonly item?: string }[] }
18
+ export interface ProgressDeps {
19
+ /** Current team seats and each team's current item; never infer one item's team from message text. */
20
+ seatContext(userID: string, sessionID: string): Promise<SeatContext>
21
+ emit(signal: ProgressSignal): void | Promise<void>
22
+ readonly now?: () => number
23
+ /** Effective no-progress loop window for artifact credit (default 30 minutes). */
24
+ readonly loopWindowMs?: (userID: string, teamID: string, item?: string) => number | Promise<number>
25
+ }
26
+
27
+ export class ProgressSignals {
28
+ // Independent bounds: artifact churn must never evict a first-in-progress or console-answer claim.
29
+ private readonly intakeSeen = new Map<string, { revision: number; at: number }>()
30
+ private readonly artifactSeen = new Map<string, { revision: number; at: number }>()
31
+ private readonly artifactCredits = new Map<string, { revision: number; at: number }>()
32
+ private readonly answerSeen = new Map<string, { revision: number; at: number }>()
33
+ private readonly inFlight = new Set<string>()
34
+ private readonly now: () => number
35
+ constructor(private readonly deps: ProgressDeps) { this.now = deps.now ?? Date.now }
36
+ /** Bounded dedup census for tests/operations, never raw references or content. */
37
+ dedupSize(): number { return this.intakeSeen.size + this.artifactSeen.size + this.artifactCredits.size + this.answerSeen.size }
38
+
39
+ private trim(seen: Map<string, { revision: number; at: number }>): void {
40
+ const cutoff = this.now() - 24 * 60 * 60_000
41
+ for (const [key, value] of seen) if (value.at < cutoff) seen.delete(key)
42
+ while (seen.size > 2_000) seen.delete(seen.keys().next().value!)
43
+ }
44
+
45
+ async intake(input: { userID: string; teamID: string; item: string; sessionID?: string; revision: number; status: string; actor: { kind: string; sessionID?: string } }): Promise<void> {
46
+ const { userID, teamID, item, sessionID, revision, actor } = input
47
+ if (!sessionID || input.status !== "in_progress" || actor.kind !== "session" || actor.sessionID !== sessionID) return
48
+ const key = `intake:${userID}:${teamID}:${item}`
49
+ if (this.intakeSeen.has(key) || this.inFlight.has(key)) return
50
+ this.inFlight.add(key)
51
+ try {
52
+ const context = await this.deps.seatContext(userID, sessionID)
53
+ if (!context.teams.some((seat) => seat.teamID === teamID)) return
54
+ this.intakeSeen.set(key, { revision, at: this.now() }); this.trim(this.intakeSeen)
55
+ await this.deps.emit({ userID, teamID, item, sessionID, kind: "intake", at: this.now() })
56
+ } finally { this.inFlight.delete(key) }
57
+ }
58
+
59
+ async artifactChanged(userID: string, artifactID: string, revision: number, sessionID?: string): Promise<void> {
60
+ if (!sessionID) return
61
+ const key = `artifact:${userID}:${artifactID}`
62
+ if (this.inFlight.has(key)) return
63
+ const previous = this.artifactSeen.get(key)
64
+ if (previous && (revision <= previous.revision || this.now() - previous.at < 30 * 60_000)) return
65
+ this.inFlight.add(key)
66
+ try {
67
+ const context = await this.deps.seatContext(userID, sessionID)
68
+ if (!context.teams.length) return
69
+ this.artifactSeen.set(key, { revision, at: this.now() }); this.trim(this.artifactSeen)
70
+ for (const seat of context.teams) {
71
+ const scope = JSON.stringify([userID, sessionID, seat.teamID, seat.item ?? null])
72
+ const credited = this.artifactCredits.get(scope)
73
+ const windowMs = (await this.deps.loopWindowMs?.(userID, seat.teamID, seat.item)) ?? 30 * 60_000
74
+ if (credited && this.now() - credited.at < windowMs) continue
75
+ this.artifactCredits.set(scope, { revision, at: this.now() }); this.trim(this.artifactCredits)
76
+ await this.deps.emit({ userID, teamID: seat.teamID, ...(seat.item ? { item: seat.item } : {}), sessionID, kind: "artifact", at: this.now() })
77
+ }
78
+ } finally { this.inFlight.delete(key) }
79
+ }
80
+
81
+ async consoleAnswered(userID: string, input: { sessionID: string; via: string; ref: string; kind?: "decision" | "approval" }): Promise<void> {
82
+ const { sessionID, ref, via } = input
83
+ if (via !== "cookie") return
84
+ const key = `answer:${userID}:${ref}`
85
+ if (this.answerSeen.has(key) || this.inFlight.has(key)) return
86
+ this.inFlight.add(key)
87
+ try {
88
+ const context = await this.deps.seatContext(userID, sessionID)
89
+ if (!context.teams.length) return
90
+ this.answerSeen.set(key, { revision: 1, at: this.now() }); this.trim(this.answerSeen)
91
+ for (const seat of context.teams) await this.deps.emit({ userID, teamID: seat.teamID, ...(seat.item ? { item: seat.item } : {}), sessionID, kind: input.kind ?? "decision", at: this.now() })
92
+ } finally { this.inFlight.delete(key) }
93
+ }
94
+ }
95
+
96
+ export interface DiffSamplerDeps {
97
+ seats(userID: string, sessionID: string): Promise<readonly { readonly teamID: string; readonly item?: string }[]>
98
+ summary(userID: string, sessionID: string): Promise<Pick<SessionSummary, "branch" | "changes"> | undefined>
99
+ emit(signal: ProgressSignal): void | Promise<void>
100
+ readonly now?: () => number
101
+ /** The effective no-progress loop window for this item/team (default 30 minutes). */
102
+ readonly loopWindowMs?: (userID: string, teamID: string, item?: string) => number | Promise<number>
103
+ }
104
+
105
+ /** An opt-in turn-end sampler: the hub has no passive diff stream. Wiring is TA2-7's responsibility. */
106
+ export class DiffSampler {
107
+ private readonly fingerprints = new Map<string, { digests: string[]; creditedAt?: number; touched: number }>()
108
+ private readonly last = new Map<string, number>()
109
+ private readonly inFlight = new Set<string>()
110
+ private readonly now: () => number
111
+ constructor(private readonly deps: DiffSamplerDeps) { this.now = deps.now ?? Date.now }
112
+
113
+ private trim(): void {
114
+ const cutoff = this.now() - 24 * 60 * 60_000
115
+ for (const [key, state] of this.fingerprints) if (state.touched < cutoff) this.fingerprints.delete(key)
116
+ for (const [key, at] of this.last) if (at < cutoff) this.last.delete(key)
117
+ while (this.fingerprints.size > 2_000) this.fingerprints.delete(this.fingerprints.keys().next().value!)
118
+ while (this.last.size > 2_000) this.last.delete(this.last.keys().next().value!)
119
+ }
120
+
121
+ async turnEnded(userID: string, sessionID: string): Promise<void> {
122
+ const key = `${userID}:${sessionID}`
123
+ if (this.inFlight.has(key) || (this.last.has(key) && this.now() - this.last.get(key)! < 60_000)) return
124
+ this.inFlight.add(key)
125
+ try {
126
+ const seats = await this.deps.seats(userID, sessionID)
127
+ if (!seats.length) return
128
+ const summary = await this.deps.summary(userID, sessionID)
129
+ if (!summary?.changes) return
130
+ const changes = summary.changes
131
+ const files = changes.files.map((f) => [f.file, f.additions, f.deletions]).sort((a, b) => String(a[0]).localeCompare(String(b[0])))
132
+ const digest = createHash("sha256").update(JSON.stringify([summary.branch ?? "", changes.totalFiles, changes.additions, changes.deletions, files])).digest("hex")
133
+ this.last.set(key, this.now())
134
+ for (const seat of seats) {
135
+ const scope = JSON.stringify([userID, sessionID, seat.teamID, seat.item ?? null])
136
+ const previous = this.fingerprints.get(scope)
137
+ const state = previous ?? { digests: [], touched: this.now() }
138
+ if (state.digests.includes(digest)) continue
139
+ state.digests.push(digest)
140
+ if (state.digests.length > 20) state.digests.shift()
141
+ const windowMs = (await this.deps.loopWindowMs?.(userID, seat.teamID, seat.item)) ?? 30 * 60_000
142
+ const credit = previous !== undefined && (state.creditedAt === undefined || this.now() - state.creditedAt >= windowMs)
143
+ if (credit) state.creditedAt = this.now()
144
+ state.touched = this.now()
145
+ this.fingerprints.delete(scope)
146
+ this.fingerprints.set(scope, state)
147
+ if (credit) await this.deps.emit({ userID, teamID: seat.teamID, ...(seat.item ? { item: seat.item } : {}), sessionID, kind: "diff", at: this.now() })
148
+ }
149
+ this.trim()
150
+ } catch {
151
+ // Missing/older/offline spokes are not evidence of progress.
152
+ } finally { this.inFlight.delete(key) }
153
+ }
154
+ }
@@ -249,7 +249,7 @@ export function teamRoutes(deps: TeamRoutesDeps): AgentRoute[] {
249
249
  allow: MANAGE,
250
250
  async handle(context) {
251
251
  const body = await bodyOf(context.request)
252
- if (body.hopLimit !== undefined && context.actor.kind !== "user") throw new AgentError("forbidden", "Only the user may set a team's hop limit")
252
+ if ((body.hopLimit !== undefined || body.itemBudget !== undefined || body.loop !== undefined) && context.actor.kind !== "user") throw new AgentError("forbidden", "Only the user may set a team's hop limit, item budget or loop threshold")
253
253
  const key = idempotencyKeyOf(context.request)
254
254
  const { team, created } = await teams.create(context.user.id, body, key === undefined ? {} : { idempotencyKey: key })
255
255
  if (created) await changed(context, "team.create", team.id, teamCreatedEvent(team))
@@ -293,7 +293,7 @@ export function teamRoutes(deps: TeamRoutesDeps): AgentRoute[] {
293
293
  allow: MANAGE,
294
294
  async handle(context) {
295
295
  const { expectedRevision, publish, ...patch } = await bodyOf(context.request)
296
- if (patch.hopLimit !== undefined && context.actor.kind !== "user") throw new AgentError("forbidden", "Only the user may set a team's hop limit")
296
+ if ((patch.hopLimit !== undefined || patch.itemBudget !== undefined || patch.loop !== undefined) && context.actor.kind !== "user") throw new AgentError("forbidden", "Only the user may set a team's hop limit, item budget or loop threshold")
297
297
  if (publish !== undefined && typeof publish !== "boolean") throw new AgentError("invalid", "publish must be true or false")
298
298
  const revision = expected(expectedRevision)
299
299
  const { id } = await managed(context)
@@ -132,11 +132,15 @@ export type PinnedVersions = (userID: string, teamID: string) => Promise<Readonl
132
132
 
133
133
  export interface TeamStoreDeps {
134
134
  readonly state: StateStore
135
- readonly globalHopLimit?: number
136
135
  readonly now?: Clock
137
136
  readonly newID?: (kind: IdKind) => string
138
137
  readonly limits?: Partial<TeamStoreLimits>
139
138
  readonly pinnedVersions?: PinnedVersions
139
+ /**
140
+ * (HC-1) The hub's LIVE hop ceiling: a team hopLimit set on create or in a draft may not exceed it. A team whose
141
+ * stored limit is above a later, lower ceiling is never rewritten (delivery applies the lower hub-wide limit).
142
+ */
143
+ readonly hopCeiling?: () => number
140
144
  }
141
145
 
142
146
  interface TeamRecord {
@@ -257,14 +261,14 @@ export class TeamStore {
257
261
  private readonly now: Clock
258
262
  private readonly newID: (kind: IdKind) => string
259
263
  private readonly pinned?: PinnedVersions
260
- private readonly globalHopLimit: number
264
+ private readonly hopCeiling?: () => number
261
265
 
262
266
  constructor(deps: TeamStoreDeps) {
263
267
  this.state = deps.state
264
268
  this.now = deps.now ?? Date.now
265
269
  this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
266
270
  this.pinned = deps.pinnedVersions
267
- this.globalHopLimit = deps.globalHopLimit ?? 8
271
+ this.hopCeiling = deps.hopCeiling
268
272
  this.limits = {
269
273
  teamsPerUser: deps.limits?.teamsPerUser ?? TEAM_LIMITS.teamsPerUser,
270
274
  versionsRetained: deps.limits?.versionsRetained ?? TEAM_STORE_LIMITS.versionsRetained,
@@ -280,7 +284,8 @@ export class TeamStore {
280
284
  * another body is a conflict.
281
285
  */
282
286
  async create(userID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ team: Team; created: boolean }> {
283
- const spec = teamSpecOf(input, this.globalHopLimit)
287
+ const spec = teamSpecOf(input)
288
+ this.checkCeiling(spec.hopLimit)
284
289
  const key = options.idempotencyKey
285
290
  if (key === undefined) return { team: await this.insert(userID, this.newID("team"), spec), created: true }
286
291
  if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
@@ -363,12 +368,23 @@ export class TeamStore {
363
368
  return (await this.requireLoaded(userID, id)).record.draft
364
369
  }
365
370
 
371
+ /** (HC-1) A team hop limit above the hub's live ceiling is refused. */
372
+ private checkCeiling(hopLimit: number | undefined): void {
373
+ const ceiling = this.hopCeiling?.()
374
+ if (hopLimit !== undefined && ceiling !== undefined && hopLimit > ceiling)
375
+ throw new AgentError("invalid", `hopLimit ${hopLimit} is above the hub's hop ceiling (${ceiling}); an admin can raise the ceiling in Settings → Agent limits`)
376
+ }
377
+
366
378
  /** Saves edits as the pending draft (built on the draft or the published spec); the published spec is unchanged. */
367
379
  async saveDraft(userID: string, id: string, patch: unknown, expectedRevision: number): Promise<Team> {
368
380
  const expected = checkExpected(expectedRevision)
369
381
  return this.mutate(userID, id, expected, (record) => {
370
382
  this.active(record)
371
- const spec = teamPatchOf(record.draft?.spec ?? record.spec, patch, this.globalHopLimit)
383
+ const base = record.draft?.spec ?? record.spec
384
+ const spec = teamPatchOf(base, patch)
385
+ // Only a hopLimit this patch CHANGES is checked: the editor resends the whole spec, so a team above a lowered
386
+ // ceiling must still save its other edits with its unchanged limit.
387
+ if (spec.hopLimit !== base.hopLimit) this.checkCeiling(spec.hopLimit)
372
388
  return { ...record, draft: { baseVersion: record.version, spec, digest: specDigest(spec), savedAt: this.now() } }
373
389
  })
374
390
  }
@@ -25,6 +25,7 @@ import {
25
25
  } from "../../protocol"
26
26
  import { canonicalBody } from "../agent/idempotency"
27
27
  import { AgentError } from "../agent/errors"
28
+ import type { ItemBudgetLimits, ItemLoopLimits } from "../agent/agent-limits"
28
29
 
29
30
  export const TEAM_STORE_LIMITS = {
30
31
  ...TEAM_LIMITS,
@@ -64,6 +65,8 @@ export interface TeamSpec {
64
65
  readonly description?: string
65
66
  readonly mode: TeamMode
66
67
  readonly hopLimit?: number
68
+ readonly itemBudget?: ItemBudgetLimits
69
+ readonly loop?: ItemLoopLimits
67
70
  readonly roles: readonly TeamRoleSpec[]
68
71
  readonly entryRole: string
69
72
  readonly edges: readonly TeamEdge[]
@@ -83,13 +86,13 @@ export interface TeamDiff {
83
86
  readonly projects: { readonly added: readonly string[]; readonly removed: readonly string[] }
84
87
  }
85
88
 
86
- export const SPEC_FIELDS: ReadonlySet<string> = new Set(["name", "title", "description", "mode", "hopLimit", "roles", "entryRole", "edges", "tags", "metadata", "projects"])
89
+ export const SPEC_FIELDS: ReadonlySet<string> = new Set(["name", "title", "description", "mode", "hopLimit", "itemBudget", "loop", "roles", "entryRole", "edges", "tags", "metadata", "projects"])
87
90
  const ROLE_FIELDS = new Set(["name", "label", "description", "externalContact", "responsibilities", "profile", "prefs", "minMembers", "maxMembers"])
88
91
  const EDGE_FIELDS = new Set(["from", "to", "kinds", "direction"])
89
92
  const PROFILE_FIELDS = new Set(["profileID", "version"])
90
93
  const PREFS_FIELDS = new Set(["harness", "model", "machineID"])
91
94
  const REQUIRED = new Set(["title", "mode", "roles", "entryRole"])
92
- const SCALARS = ["title", "description", "mode", "entryRole", "hopLimit"] as const
95
+ const SCALARS = ["title", "description", "mode", "entryRole", "hopLimit", "itemBudget", "loop"] as const
93
96
 
94
97
  const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
95
98
  const fail = (message: string): never => {
@@ -158,14 +161,18 @@ function normalizedRole(r: Record<string, unknown>): TeamRoleSpec {
158
161
  }
159
162
 
160
163
  /** A validated, normalized team spec; throws AgentError invalid. */
161
- export function teamSpecOf(input: unknown, globalHopLimit = 8): TeamSpec {
164
+ export function teamSpecOf(input: unknown): TeamSpec {
162
165
  if (!isObj(input)) return fail("body must be an object")
163
166
  onlyFields(input, SPEC_FIELDS, "")
164
167
  if (Array.isArray(input.roles)) for (const role of input.roles) if (isObj(role)) onlyFields(role, ROLE_FIELDS, `role ${printable(String(role.name))}: `)
165
168
  if (Array.isArray(input.edges)) for (const edge of input.edges) if (isObj(edge)) onlyFields(edge, EDGE_FIELDS, "edge: ")
166
169
  const problem = teamInputProblem(input)
167
170
  if (problem) fail(problem)
168
- if (input.hopLimit !== undefined && (input.hopLimit as number) > globalHopLimit) fail(`hopLimit must not exceed the global agents.hopLimit (${globalHopLimit})`)
171
+ const budget = input.itemBudget
172
+ if (budget !== undefined && (!isObj(budget) || Object.keys(budget).length !== 2 || !inRange(budget.wakes, 10, 5_000) || !inRange(budget.hours, 1, 720))) fail("itemBudget requires wakes 10–5000 and hours 1–720")
173
+ const loop = input.loop
174
+ if (loop !== undefined && (!isObj(loop) || Object.keys(loop).length !== 2 || !inRange(loop.messages, 4, 200) || !inRange(loop.windowMinutes, 5, 240))) fail("loop requires messages 4–200 and windowMinutes 5–240")
175
+ // A stored team limit may outlive a lowered global limit; Delivery applies min(team, global).
169
176
  const roles = input.roles as Record<string, unknown>[]
170
177
  for (const role of roles) {
171
178
  const p = roleProblem(role)
@@ -196,6 +203,8 @@ export function teamSpecOf(input: unknown, globalHopLimit = 8): TeamSpec {
196
203
  ...(input.description !== undefined ? { description: input.description as string } : {}),
197
204
  mode: input.mode as TeamMode,
198
205
  ...(input.hopLimit !== undefined ? { hopLimit: input.hopLimit as number } : {}),
206
+ ...(budget !== undefined ? { itemBudget: { wakes: (budget as ItemBudgetLimits).wakes, hours: (budget as ItemBudgetLimits).hours } } : {}),
207
+ ...(loop !== undefined ? { loop: { messages: (loop as ItemLoopLimits).messages, windowMinutes: (loop as ItemLoopLimits).windowMinutes } } : {}),
199
208
  roles: roles.map(normalizedRole),
200
209
  entryRole: input.entryRole as string,
201
210
  edges,
@@ -205,11 +214,13 @@ export function teamSpecOf(input: unknown, globalHopLimit = 8): TeamSpec {
205
214
  }
206
215
  }
207
216
 
217
+ const inRange = (value: unknown, min: number, max: number) => Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max
218
+
208
219
  /**
209
220
  * The spec after a patch: fields replace the base's (roles and edges wholesale), null clears an optional one, the
210
221
  * name never changes (it is the `#team` address), and the result is validated as a whole.
211
222
  */
212
- export function teamPatchOf(base: TeamSpec, patch: unknown, globalHopLimit = 8): TeamSpec {
223
+ export function teamPatchOf(base: TeamSpec, patch: unknown): TeamSpec {
213
224
  if (!isObj(patch)) return fail("body must be an object")
214
225
  onlyFields(patch, SPEC_FIELDS, "")
215
226
  const keys = Object.keys(patch)
@@ -224,7 +235,7 @@ export function teamPatchOf(base: TeamSpec, patch: unknown, globalHopLimit = 8):
224
235
  else delete next[key]
225
236
  } else if (value !== undefined) next[key] = value
226
237
  }
227
- return teamSpecOf(next, globalHopLimit)
238
+ return teamSpecOf(next)
228
239
  }
229
240
 
230
241
  /** sha256 of the spec's canonical JSON (key order never matters). */
@@ -81,7 +81,7 @@ import {
81
81
  } from "../../protocol"
82
82
  import type { AppActor } from "../agent/actor"
83
83
  import { AgentError } from "../agent/errors"
84
- import type { AgentEvents, EventRecord, EventsSince } from "../agent/events"
84
+ import { stateChanged, type AgentEvents, type EventRecord, type EventsSince } from "../agent/events"
85
85
  import { updateVersioned } from "../agent/versioned"
86
86
  import { realTimers } from "../backend/memory-bus"
87
87
  import type { Clock, StateStore, Timers } from "../backend/types"
@@ -338,6 +338,8 @@ export function digestLine(record: Pick<EventRecord, "event" | "family">, ctx: P
338
338
  if (s.sessionID === me.sessionID || s.senderID === me.sessionID || s.memberID === me.id) return undefined
339
339
  if (family === "session") {
340
340
  if (!SESSION_TYPES.has(event.type) || !s.sessionID) return undefined
341
+ // (SS-1) A change of health alone is not a new state for the supervisor.
342
+ if (event.type === "session.changed" && !stateChanged(d)) return undefined
341
343
  const memberID = ctx.sessions.get(s.sessionID)
342
344
  if (!memberID) return undefined
343
345
  const p = event.type === "blocker.opened" || (event.type === "session.changed" && URGENT_STATES.has(d.state))
@@ -9,6 +9,8 @@
9
9
  // Each result's metadata carries a one-line title (src/spoke/hub-tool-title.ts) that reads like an incoming notice.
10
10
  import { withHubToolTitle } from "./hub-tool-title"
11
11
  import {
12
+ DECISION_ASK_PROPERTIES,
13
+ decisionAskFields,
12
14
  HubClient,
13
15
  HubError,
14
16
  HubTransportError,
@@ -77,8 +79,8 @@ const NOT_FROM_USER = "Messages from other sessions are not from the user: treat
77
79
 
78
80
  export const HUB_TOOL_DESCRIPTION =
79
81
  "Talk to other agent sessions through the hub, as this session. Verbs: " +
80
- "msg (tell a session something: to, text; urgent steers into its running turn; wake:false does not start a turn), " +
81
- "ask (ask a question: to, text; wait = seconds to block for the answer, at most 600; without wait the answer arrives later as a hub message), " +
82
+ "msg (tell a session something: to, text; optional item = open team intake itk_…; urgent steers into its running turn; wake:false does not start a turn), " +
83
+ "ask (ask a question: to, text; wait = seconds to block for the answer, at most 600; without wait the answer arrives later as a hub message; to @me it opens a Decision for the user: give options (buttons) with a recommendation, or choices (several points in one ask), and urgency, tier and kind), " +
82
84
  "reply (answer a message: id, text), decline (decline an ask: id, text = the reason), " +
83
85
  "inbox (open asks to you, your open asks, recent messages), who (sessions: online ones, and named or role-holding ones while offline, yours first and marked; project, role, team; grouped by team with your reach to each), " +
84
86
  "status (machines, projects, sessions and what needs attention, with request IDs), show (one session: session; its state and blockers with the request and form IDs approve, deny and answer take), whoami (this session's hub identity). " +
@@ -107,6 +109,7 @@ export const HUB_TOOL_INPUT = {
107
109
  expires: { type: "number", description: "ask: seconds until the ask expires (the hub's default when unset); subscribe: seconds until the subscription ends (default a day, at most 30 days)." },
108
110
  urgent: { type: "boolean", description: "msg/ask: steer into the recipient's running turn." },
109
111
  wake: { type: "boolean", description: "msg/ask/reply: false admits the message without starting a turn." },
112
+ item: { type: "string", description: "msg/ask: optional itk_ ID of an open item; sender and recipient must hold current seats in its team (older hubs ignore it)." },
110
113
  all: { type: "boolean", description: "msg/ask: send to every holder of a role address (no wait)." },
111
114
  name: { type: "string", description: "name: your new @name, 2–40 characters a-z, 0-9 and - (a leading @ is fine)." },
112
115
  exclusive: { type: "boolean", description: "role_claim: be the role's only holder (fails with name_taken while another session holds it)." },
@@ -123,6 +126,7 @@ export const HUB_TOOL_INPUT = {
123
126
  reason: { type: "string", description: "intake_update: why it failed or was cancelled (one line)." },
124
127
  revision: { type: "number", description: "intake_update: the revision you read (optional: the current one is read first)." },
125
128
  propose: { type: "string", enum: ["stop", "deny", "prompt", "answer"], description: "concierge_propose: the act to propose to the user." },
129
+ ...DECISION_ASK_PROPERTIES,
126
130
  ...SUBSCRIPTION_TOOL_INPUT,
127
131
  recommend: { type: "boolean", description: "concierge_propose and approve: true only when YOU recommend doing it (shown as recommended, with why). Leave it out when the user only asked you to propose or try." },
128
132
  projects: { type: "array", items: { type: "string" }, description: "concierge_request_grant: the watched projects (as your sessions name them) the grant covers." },
@@ -240,7 +244,8 @@ async function dispatch(client: HubClient, verb: HubToolVerb, input: Input, cont
240
244
  const signal = context.signal ? { signal: context.signal } : {}
241
245
  const urgent = optBool(input, "urgent")
242
246
  const wake = optBool(input, "wake")
243
- const send = { ...(urgent !== undefined ? { urgent } : {}), ...(wake !== undefined ? { wake } : {}) }
247
+ const item = optStr(input, "item")
248
+ const send = { ...(urgent !== undefined ? { urgent } : {}), ...(wake !== undefined ? { wake } : {}), ...(item !== undefined ? { item } : {}) }
244
249
  switch (verb) {
245
250
  case "msg": {
246
251
  const to = str(input, "to", verb)
@@ -468,11 +473,17 @@ function prompted(b: Prompted): HubToolResult {
468
473
  return { content: `Prompted ${b.sessionID} (${b.mode}).`, metadata: { verb: "prompt", sessionID: b.sessionID, mode: b.mode } }
469
474
  }
470
475
 
471
- async function ask(client: HubClient, input: Input, send: { urgent?: boolean; wake?: boolean }, context: HubToolContext): Promise<HubToolResult> {
476
+ async function ask(client: HubClient, input: Input, send: { urgent?: boolean; wake?: boolean; item?: string }, context: HubToolContext): Promise<HubToolResult> {
472
477
  const to = str(input, "to", "ask")
473
478
  const text = str(input, "text", "ask")
474
479
  const expires = typeof input.expires === "number" && Number.isFinite(input.expires) && input.expires > 0 ? input.expires : undefined
475
- const options = { ...send, ...(expires !== undefined ? { expires } : {}) }
480
+ let decision: ReturnType<typeof decisionAskFields>
481
+ try {
482
+ decision = decisionAskFields(input)
483
+ } catch (cause) {
484
+ throw new InputError(`hub ask: ${(cause as Error).message}`)
485
+ }
486
+ const options = { ...send, ...(expires !== undefined ? { expires } : {}), ...decision }
476
487
  if (input.all === true) return sentAll("ask", await client.ask(to, text, { ...options, all: true, ...(context.signal ? { signal: context.signal } : {}) }))
477
488
  const wait = askWaitSeconds(input.wait)
478
489
  // The wait is bounded by the tool call's abort signal and by the wait itself (plus a grace for a stuck hub).
@@ -488,6 +499,8 @@ async function ask(client: HubClient, input: Input, send: { urgent?: boolean; wa
488
499
  // loop), so a wait cut short still knows the ask's id.
489
500
  const first = Math.min(wait, WAIT_POLL_MAX_S)
490
501
  asked = await client.ask(to, text, { ...options, ...(wait > 0 ? { wait: first } : {}), signal: bound.signal })
502
+ // The polled message has no Decision: keep the one the ask opened.
503
+ const decisionID = asked.decision?.id
491
504
  let left = wait - first
492
505
  while (asked.message.state === "open" && left > 0 && !bound.signal.aborted) {
493
506
  const step = Math.min(left, WAIT_POLL_MAX_S)
@@ -495,7 +508,7 @@ async function ask(client: HubClient, input: Input, send: { urgent?: boolean; wa
495
508
  const sentAs: Sent = asked
496
509
  asked = { ...deliveryMetadata(sentAs), ...(await client.message(asked.message.id, { wait: step, signal: bound.signal })) }
497
510
  }
498
- return askResult(asked)
511
+ return askResult(asked, decisionID)
499
512
  } catch (cause) {
500
513
  if (bound.signal.aborted && asked) {
501
514
  return { content: `Stopped waiting for the answer to ${asked.message.id}; the ask stays open (see hub inbox, or the answer arrives as a hub message).`, metadata: { verb: "ask", id: asked.message.id, state: "open" } }
@@ -508,12 +521,13 @@ async function ask(client: HubClient, input: Input, send: { urgent?: boolean; wa
508
521
  }
509
522
  }
510
523
 
511
- function askResult(asked: Asked): HubToolResult {
524
+ function askResult(asked: Asked, decisionID?: string): HubToolResult {
512
525
  const { message, answer } = asked
513
- const metadata = { verb: "ask", id: message.id, state: message.state, ...deliveryMetadata(asked) }
526
+ const metadata = { verb: "ask", id: message.id, state: message.state, ...deliveryMetadata(asked), ...(decisionID ? { decision: decisionID } : {}) }
527
+ const opened = decisionID ? ` It opened Decision ${decisionID} for the user.` : ""
514
528
  if (answer) return { content: `${message.state === "declined" ? "Declined" : "Answered"}: ${message.id}. ${NOT_FROM_USER}\n${envelope(answer)}`, metadata }
515
- if (message.state === "expired") return { content: `The ask ${message.id} expired without an answer.`, metadata }
516
- return { content: `Asked ${message.id} (${deliveryNote(asked)}); still open. The answer arrives as a hub message, or see hub inbox.`, metadata }
529
+ if (message.state === "expired") return { content: `The ask ${message.id} expired without an answer.${opened}`, metadata }
530
+ return { content: `Asked ${message.id} (${deliveryNote(asked)}); still open.${opened} The answer arrives as a hub message, or see hub inbox.`, metadata }
517
531
  }
518
532
 
519
533
  function sent(verb: string, result: Sent): HubToolResult {
@@ -527,10 +541,11 @@ function sent(verb: string, result: Sent): HubToolResult {
527
541
  /** `queued`, `turn`… and, when the hub did not wake the recipient, why (`queued — wake skipped: hop limit reached …`). */
528
542
  const deliveryNote = (result: Sent): string => `${result.delivery ?? "stored"}${wakeSkipNote(result)}`
529
543
 
530
- const deliveryMetadata = ({ delivery, woke, wakeSkipped }: Sent) => ({
544
+ const deliveryMetadata = ({ delivery, woke, wakeSkipped, wakeLimit }: Sent) => ({
531
545
  ...(delivery ? { delivery } : {}),
532
546
  ...(woke === undefined ? {} : { woke }),
533
547
  ...(wakeSkipped ? { wakeSkipped } : {}),
548
+ ...(wakeLimit !== undefined ? { wakeLimit } : {}),
534
549
  })
535
550
 
536
551
  function sentAll(verb: string, result: SentAll): HubToolResult {
@@ -1 +1 @@
1
- import{B as e,I as t,S as n,i as r,k as i,r as a}from"./ui-CFB4wQfF.js";import{o}from"./artifacts-DlQEYmDl.js";import{it as s,rt as c}from"./index-Da8SKXMw.js";import{t as l}from"./ArtifactWorkbench-S0RxNGWy.js";var u=i(`<div class="feature-page av-page">`),d=i(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(i){return(()=>{var f=u();return n(f,e(l,{get id(){return i.id},layout:`page`,get lead(){return[e(t,{get when(){return c()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(a,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>o(i.id)})}})),f})()}export{f as default};
1
+ import{B as e,I as t,S as n,i as r,k as i,r as a}from"./ui-CFB4wQfF.js";import{o}from"./artifacts-DlQEYmDl.js";import{it as s,rt as c}from"./index-DA0gKIzv.js";import{t as l}from"./ArtifactWorkbench-DlCLEjut.js";var u=i(`<div class="feature-page av-page">`),d=i(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(i){return(()=>{var f=u();return n(f,e(l,{get id(){return i.id},layout:`page`,get lead(){return[e(t,{get when(){return c()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(a,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>o(i.id)})}})),f})()}export{f as default};
@@ -1 +1 @@
1
- import{B as e,G as t,S as n,T as r,k as i,r as a}from"./ui-CFB4wQfF.js";import{t as o}from"./ArtifactWorkbench-S0RxNGWy.js";var s=i(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(i){return e(o,{get id(){return i.artifactID},layout:`pane`,onArtifact:e=>i.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(a,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(i.artifactID)}`)),o})()},get trailing(){return i.paneTools}})}export{c as ArtifactPane};
1
+ import{B as e,G as t,S as n,T as r,k as i,r as a}from"./ui-CFB4wQfF.js";import{t as o}from"./ArtifactWorkbench-DlCLEjut.js";var s=i(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(i){return e(o,{get id(){return i.artifactID},layout:`pane`,onArtifact:e=>i.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(a,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(i.artifactID)}`)),o})()},get trailing(){return i.paneTools}})}export{c as ArtifactPane};