@huynle/hub 0.10.1 → 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.
- package/CHANGELOG.md +80 -0
- package/README.md +36 -8
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/sessions.ts +4 -1
- package/packages/cli/dist/hub-cli.js +851 -466
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +12 -7
- package/packages/cli/src/decisions.ts +52 -4
- package/packages/cli/src/format.ts +9 -0
- package/packages/cli/src/grants-mcp.ts +65 -0
- package/packages/cli/src/mcp-operations.ts +49 -0
- package/packages/cli/src/mcp.ts +26 -4
- package/packages/cli/src/subscriptions-mcp.ts +76 -0
- package/packages/cli/src/team-intake-mcp.ts +2 -1
- package/packages/cli/src/teams-mcp.ts +9 -3
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +14 -3
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/api/v1/_root.json +119 -0
- package/packages/protocol/schema/api/v1/account.json +419 -0
- package/packages/protocol/schema/api/v1/decisions.json +635 -0
- package/packages/protocol/schema/api/v1/discovery.json +110 -0
- package/packages/protocol/schema/api/v1/events.json +679 -0
- package/packages/protocol/schema/api/v1/session-actions.json +660 -0
- package/packages/protocol/schema/api/v1/sessions.json +2055 -0
- package/packages/protocol/schema/api/v1/speech.json +492 -0
- package/packages/protocol/schema/api/v1/unspecified.json +282 -0
- package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
- package/packages/protocol/schema/app-front/v1.schema.json +7 -3
- package/packages/protocol/src/api-v1.ts +8 -0
- package/packages/protocol/src/index.ts +2 -0
- package/packages/protocol/src/teams.ts +16 -0
- package/packages/protocol/src/voice-recording.ts +39 -2
- package/packages/runtime-openshell/image/entry.sh +3 -1
- package/packages/runtime-openshell/image/start.sh +3 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +2 -2
- package/packages/sdk/src/ask-fields.ts +76 -0
- package/packages/sdk/src/client.ts +24 -2
- package/packages/sdk/src/envelope.ts +3 -2
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/team-intake.ts +6 -0
- package/packages/sdk/src/teams.ts +4 -0
- package/packages/sdk/src/types.ts +36 -4
- package/packages/sdk/src/wake.ts +9 -4
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/agent-limits.ts +185 -0
- package/src/central/agent/app-decisions.ts +6 -5
- package/src/central/agent/decisions.ts +136 -9
- package/src/central/agent/delivery.ts +164 -22
- package/src/central/agent/errors.ts +2 -1
- package/src/central/agent/events.ts +20 -2
- package/src/central/agent/grant-routes.ts +14 -1
- package/src/central/agent/grants.ts +23 -0
- package/src/central/agent/launch-engine.ts +9 -6
- package/src/central/agent/message-limits.ts +6 -0
- package/src/central/agent/message-routes.ts +33 -6
- package/src/central/agent/messages.ts +7 -0
- package/src/central/agent/placement.ts +4 -2
- package/src/central/agent/read-routes.ts +5 -0
- package/src/central/agent/routes.ts +13 -0
- package/src/central/agent/subscriptions.ts +15 -4
- package/src/central/api-v1.ts +115 -0
- package/src/central/artifacts/store.ts +4 -2
- package/src/central/concierge/board-results.ts +2 -1
- package/src/central/concierge/notifier.ts +4 -2
- package/src/central/config.ts +24 -3
- package/src/central/health/classify.ts +209 -0
- package/src/central/http.ts +380 -36
- package/src/central/recording/export.ts +57 -3
- package/src/central/recording/index.ts +236 -17
- package/src/central/recording/ingest.ts +11 -2
- package/src/central/recording/routes.ts +61 -7
- package/src/central/recording/schema.ts +68 -3
- package/src/central/recording/settings.ts +19 -5
- package/src/central/recording/store.ts +19 -0
- package/src/central/recording/taps.ts +45 -1
- package/src/central/registry.ts +34 -3
- package/src/central/speech/openai.ts +4 -4
- package/src/central/speech/openrouter.ts +4 -4
- package/src/central/speech/provider.ts +32 -1
- package/src/central/speech/service.ts +6 -5
- package/src/central/teams/budgets.ts +144 -0
- package/src/central/teams/hop-stalls.ts +80 -0
- package/src/central/teams/intake.ts +38 -6
- package/src/central/teams/involvement.ts +95 -0
- package/src/central/teams/item-decisions.ts +60 -0
- package/src/central/teams/item-eligibility.ts +35 -0
- package/src/central/teams/item-stalls.ts +87 -0
- package/src/central/teams/loops.ts +40 -0
- package/src/central/teams/policy.ts +10 -0
- package/src/central/teams/progress.ts +154 -0
- package/src/central/teams/routes.ts +2 -0
- package/src/central/teams/seats.ts +3 -3
- package/src/central/teams/staffing.ts +5 -0
- package/src/central/teams/store.ts +20 -1
- package/src/central/teams/validation.ts +16 -2
- package/src/central/workspaces/coordinator.ts +3 -1
- package/src/spoke/hub-tool.ts +26 -11
- package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-TbFJi59n.js} +1 -1
- package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-7D-M-Ich.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
- package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-PvOPetOI.js} +1 -1
- package/web/dist/assets/{Library-rv5DGtrm.js → Library-Cdi83pOV.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-cryC7P6j.js} +1 -1
- package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
- package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
- package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
- package/web/dist/assets/index-DA0gKIzv.js +33 -0
- package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
- package/web/dist/assets/live-B2IfKm37.js +5 -0
- package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
- package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
- package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-CsZnF3_o.js} +1 -1
- package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-BIMA0wFn.js} +2 -2
- package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-dtrnw9NF.js} +1 -1
- package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
- package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
- package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/dist/assets/TeamsPage-D_17MkvN.js +0 -1
- package/web/dist/assets/Watchtower-DlrNOJ7X.js +0 -2
- package/web/dist/assets/index-bX8B6NLo.js +0 -33
- package/web/dist/assets/live-BGLA-peO.js +0 -5
|
@@ -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
|
+
}
|
|
@@ -109,6 +109,16 @@ const sessionParty = (entry: DirectoryEntry): Party => ({ kind: "session", sessi
|
|
|
109
109
|
export class TeamPolicy {
|
|
110
110
|
constructor(private readonly deps: TeamPolicyDeps) {}
|
|
111
111
|
|
|
112
|
+
/** Shared active seat(s) only; cross-team and non-team sends use the global limit. */
|
|
113
|
+
async hopContext(userID: string, from: Party, to: Party, explicitTeamID?: string): Promise<{ teamID: string; limit?: number } | undefined> {
|
|
114
|
+
if (from.kind !== "session" || to.kind !== "session") return undefined
|
|
115
|
+
const ctx = await this.context(userID, [from.sessionID, to.sessionID])
|
|
116
|
+
const shared = seatsIn(ctx, from.sessionID).map((s) => s.teamID).filter((id) => (explicitTeamID === undefined || explicitTeamID === id) && seatsIn(ctx, to.sessionID).some((s) => s.teamID === id) && ctx.teams.get(id)?.state === "active").sort()
|
|
117
|
+
const teamID = shared[0]
|
|
118
|
+
if (!teamID) return undefined
|
|
119
|
+
return { teamID, ...(ctx.teams.get(teamID)?.hopLimit !== undefined ? { limit: ctx.teams.get(teamID)!.hopLimit } : {}) }
|
|
120
|
+
}
|
|
121
|
+
|
|
112
122
|
/** Starts a send from `from` to `addr` (see the header). */
|
|
113
123
|
async prepare(userID: string, from: Party, addr: string): Promise<TeamSend> {
|
|
114
124
|
const resolved = await this.resolve(userID, from, addr)
|
|
@@ -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,6 +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 || 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")
|
|
252
253
|
const key = idempotencyKeyOf(context.request)
|
|
253
254
|
const { team, created } = await teams.create(context.user.id, body, key === undefined ? {} : { idempotencyKey: key })
|
|
254
255
|
if (created) await changed(context, "team.create", team.id, teamCreatedEvent(team))
|
|
@@ -292,6 +293,7 @@ export function teamRoutes(deps: TeamRoutesDeps): AgentRoute[] {
|
|
|
292
293
|
allow: MANAGE,
|
|
293
294
|
async handle(context) {
|
|
294
295
|
const { expectedRevision, publish, ...patch } = await bodyOf(context.request)
|
|
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")
|
|
295
297
|
if (publish !== undefined && typeof publish !== "boolean") throw new AgentError("invalid", "publish must be true or false")
|
|
296
298
|
const revision = expected(expectedRevision)
|
|
297
299
|
const { id } = await managed(context)
|
|
@@ -47,7 +47,7 @@ export interface TeamSeatRecord extends TeamStaffing {
|
|
|
47
47
|
readonly teamVersion: number
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export type DeliveryTeams = ReadonlyMap<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state">>
|
|
50
|
+
export type DeliveryTeams = ReadonlyMap<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state" | "hopLimit">>
|
|
51
51
|
|
|
52
52
|
export interface DeliveryContext {
|
|
53
53
|
/** Each asked session's current seats (empty when it holds none). */
|
|
@@ -199,11 +199,11 @@ export class TeamSeatStore {
|
|
|
199
199
|
/** Seats of several sessions and the teams they are in: teamDeliveryVerdict's inputs (HWA-34). */
|
|
200
200
|
async deliveryContext(userID: string, sessionIDs: readonly string[]): Promise<DeliveryContext> {
|
|
201
201
|
const seats = await this.seatsBySession(userID, sessionIDs)
|
|
202
|
-
const teams = new Map<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state">>()
|
|
202
|
+
const teams = new Map<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state" | "hopLimit">>()
|
|
203
203
|
for (const teamID of new Set([...seats.values()].flat().map((s) => s.teamID))) {
|
|
204
204
|
try {
|
|
205
205
|
const team = await this.teams.get(userID, teamID)
|
|
206
|
-
if (team) teams.set(teamID, { mode: team.mode, edges: team.edges, roles: team.roles, state: team.state })
|
|
206
|
+
if (team) teams.set(teamID, { mode: team.mode, edges: team.edges, roles: team.roles, state: team.state, ...(team.hopLimit !== undefined ? { hopLimit: team.hopLimit } : {}) })
|
|
207
207
|
} catch {
|
|
208
208
|
// A team this build cannot read takes no part in delivery (its seats are then ignored by the verdict).
|
|
209
209
|
}
|
|
@@ -75,6 +75,7 @@ import { readSeatsDoc, type SeatsDoc, TEAM_SEATS } from "./collections"
|
|
|
75
75
|
import { teamStaffedEvent, teamUnstaffedEvent } from "./events"
|
|
76
76
|
import type { TeamSeatRecord, TeamSeatStore } from "./seats"
|
|
77
77
|
import type { Team, TeamStore } from "./store"
|
|
78
|
+
import type { TeamHopStalls } from "./hop-stalls"
|
|
78
79
|
import { TEAM_STORE_LIMITS, type TeamRoleSpec } from "./validation"
|
|
79
80
|
|
|
80
81
|
/** One aggregate per team (key: team ID): workspace link, managed members, launch requests, Idempotency-Keys. */
|
|
@@ -144,6 +145,8 @@ export interface SeatProfileStatus {
|
|
|
144
145
|
export interface RosterSeat {
|
|
145
146
|
readonly staffing: TeamSeatRecord
|
|
146
147
|
readonly presence: SessionPresence
|
|
148
|
+
/** An agent-only message to this seat hit the team's hop limit; only the user may renew. */
|
|
149
|
+
readonly stalled?: "hop cap"
|
|
147
150
|
readonly memberID?: string
|
|
148
151
|
readonly supervisorMemberID?: string
|
|
149
152
|
readonly profile?: SeatProfileStatus
|
|
@@ -208,6 +211,7 @@ export interface TeamStaffingDeps {
|
|
|
208
211
|
readonly members: MembershipStore
|
|
209
212
|
readonly supervision: SupervisionStore
|
|
210
213
|
readonly presence: SessionPresenceLookup
|
|
214
|
+
readonly hopStalls?: Pick<TeamHopStalls, "stalled">
|
|
211
215
|
/** The session's project directory now (grant scope); undefined when no spoke reports it. */
|
|
212
216
|
readonly sessionDirectory: (userID: string, sessionID: string) => string | undefined
|
|
213
217
|
readonly launches: Pick<LaunchEngine, "accept" | "view">
|
|
@@ -454,6 +458,7 @@ export class TeamStaffingService {
|
|
|
454
458
|
seats.push({
|
|
455
459
|
staffing,
|
|
456
460
|
presence: presence.get(staffing.sessionID)!,
|
|
461
|
+
...(await this.deps.hopStalls?.stalled(userID, teamID, staffing.sessionID) ? { stalled: "hop cap" as const } : {}),
|
|
457
462
|
...(memberID !== undefined ? { memberID } : {}),
|
|
458
463
|
...(supervisorMemberID !== undefined ? { supervisorMemberID } : {}),
|
|
459
464
|
...(profile ? { profile } : {}),
|
|
@@ -136,6 +136,11 @@ export interface TeamStoreDeps {
|
|
|
136
136
|
readonly newID?: (kind: IdKind) => string
|
|
137
137
|
readonly limits?: Partial<TeamStoreLimits>
|
|
138
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
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
interface TeamRecord {
|
|
@@ -256,12 +261,14 @@ export class TeamStore {
|
|
|
256
261
|
private readonly now: Clock
|
|
257
262
|
private readonly newID: (kind: IdKind) => string
|
|
258
263
|
private readonly pinned?: PinnedVersions
|
|
264
|
+
private readonly hopCeiling?: () => number
|
|
259
265
|
|
|
260
266
|
constructor(deps: TeamStoreDeps) {
|
|
261
267
|
this.state = deps.state
|
|
262
268
|
this.now = deps.now ?? Date.now
|
|
263
269
|
this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
|
|
264
270
|
this.pinned = deps.pinnedVersions
|
|
271
|
+
this.hopCeiling = deps.hopCeiling
|
|
265
272
|
this.limits = {
|
|
266
273
|
teamsPerUser: deps.limits?.teamsPerUser ?? TEAM_LIMITS.teamsPerUser,
|
|
267
274
|
versionsRetained: deps.limits?.versionsRetained ?? TEAM_STORE_LIMITS.versionsRetained,
|
|
@@ -278,6 +285,7 @@ export class TeamStore {
|
|
|
278
285
|
*/
|
|
279
286
|
async create(userID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ team: Team; created: boolean }> {
|
|
280
287
|
const spec = teamSpecOf(input)
|
|
288
|
+
this.checkCeiling(spec.hopLimit)
|
|
281
289
|
const key = options.idempotencyKey
|
|
282
290
|
if (key === undefined) return { team: await this.insert(userID, this.newID("team"), spec), created: true }
|
|
283
291
|
if (!IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
|
|
@@ -360,12 +368,23 @@ export class TeamStore {
|
|
|
360
368
|
return (await this.requireLoaded(userID, id)).record.draft
|
|
361
369
|
}
|
|
362
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
|
+
|
|
363
378
|
/** Saves edits as the pending draft (built on the draft or the published spec); the published spec is unchanged. */
|
|
364
379
|
async saveDraft(userID: string, id: string, patch: unknown, expectedRevision: number): Promise<Team> {
|
|
365
380
|
const expected = checkExpected(expectedRevision)
|
|
366
381
|
return this.mutate(userID, id, expected, (record) => {
|
|
367
382
|
this.active(record)
|
|
368
|
-
const
|
|
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)
|
|
369
388
|
return { ...record, draft: { baseVersion: record.version, spec, digest: specDigest(spec), savedAt: this.now() } }
|
|
370
389
|
})
|
|
371
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,
|
|
@@ -63,6 +64,9 @@ export interface TeamSpec {
|
|
|
63
64
|
readonly title: string
|
|
64
65
|
readonly description?: string
|
|
65
66
|
readonly mode: TeamMode
|
|
67
|
+
readonly hopLimit?: number
|
|
68
|
+
readonly itemBudget?: ItemBudgetLimits
|
|
69
|
+
readonly loop?: ItemLoopLimits
|
|
66
70
|
readonly roles: readonly TeamRoleSpec[]
|
|
67
71
|
readonly entryRole: string
|
|
68
72
|
readonly edges: readonly TeamEdge[]
|
|
@@ -82,13 +86,13 @@ export interface TeamDiff {
|
|
|
82
86
|
readonly projects: { readonly added: readonly string[]; readonly removed: readonly string[] }
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
export const SPEC_FIELDS: ReadonlySet<string> = new Set(["name", "title", "description", "mode", "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"])
|
|
86
90
|
const ROLE_FIELDS = new Set(["name", "label", "description", "externalContact", "responsibilities", "profile", "prefs", "minMembers", "maxMembers"])
|
|
87
91
|
const EDGE_FIELDS = new Set(["from", "to", "kinds", "direction"])
|
|
88
92
|
const PROFILE_FIELDS = new Set(["profileID", "version"])
|
|
89
93
|
const PREFS_FIELDS = new Set(["harness", "model", "machineID"])
|
|
90
94
|
const REQUIRED = new Set(["title", "mode", "roles", "entryRole"])
|
|
91
|
-
const SCALARS = ["title", "description", "mode", "entryRole"] as const
|
|
95
|
+
const SCALARS = ["title", "description", "mode", "entryRole", "hopLimit", "itemBudget", "loop"] as const
|
|
92
96
|
|
|
93
97
|
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
94
98
|
const fail = (message: string): never => {
|
|
@@ -164,6 +168,11 @@ export function teamSpecOf(input: unknown): TeamSpec {
|
|
|
164
168
|
if (Array.isArray(input.edges)) for (const edge of input.edges) if (isObj(edge)) onlyFields(edge, EDGE_FIELDS, "edge: ")
|
|
165
169
|
const problem = teamInputProblem(input)
|
|
166
170
|
if (problem) fail(problem)
|
|
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).
|
|
167
176
|
const roles = input.roles as Record<string, unknown>[]
|
|
168
177
|
for (const role of roles) {
|
|
169
178
|
const p = roleProblem(role)
|
|
@@ -193,6 +202,9 @@ export function teamSpecOf(input: unknown): TeamSpec {
|
|
|
193
202
|
title: input.title as string,
|
|
194
203
|
...(input.description !== undefined ? { description: input.description as string } : {}),
|
|
195
204
|
mode: input.mode as TeamMode,
|
|
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 } } : {}),
|
|
196
208
|
roles: roles.map(normalizedRole),
|
|
197
209
|
entryRole: input.entryRole as string,
|
|
198
210
|
edges,
|
|
@@ -202,6 +214,8 @@ export function teamSpecOf(input: unknown): TeamSpec {
|
|
|
202
214
|
}
|
|
203
215
|
}
|
|
204
216
|
|
|
217
|
+
const inRange = (value: unknown, min: number, max: number) => Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max
|
|
218
|
+
|
|
205
219
|
/**
|
|
206
220
|
* The spec after a patch: fields replace the base's (roles and edges wholesale), null clears an optional one, the
|
|
207
221
|
* name never changes (it is the `#team` address), and the result is validated as a whole.
|
|
@@ -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
|
|
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))
|
package/src/spoke/hub-tool.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
516
|
-
return { content: `Asked ${message.id} (${deliveryNote(asked)}); still open
|
|
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-
|
|
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-
|
|
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};
|