@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.
- package/CHANGELOG.md +26 -0
- package/README.md +19 -5
- 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 +240 -83
- 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/mcp.ts +20 -4
- package/packages/cli/src/team-intake-mcp.ts +2 -1
- package/packages/cli/src/teams-mcp.ts +7 -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 +13 -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 +11 -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 +2 -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 +130 -9
- package/src/central/agent/delivery.ts +134 -22
- package/src/central/agent/errors.ts +2 -1
- package/src/central/agent/events.ts +20 -2
- package/src/central/agent/launch-engine.ts +6 -3
- package/src/central/agent/message-limits.ts +5 -2
- 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 +2 -0
- package/src/central/agent/subscriptions.ts +13 -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 +20 -6
- package/src/central/health/classify.ts +209 -0
- package/src/central/http.ts +285 -23
- package/src/central/recording/index.ts +2 -1
- package/src/central/recording/schema.ts +2 -1
- package/src/central/registry.ts +34 -3
- package/src/central/teams/budgets.ts +144 -0
- package/src/central/teams/hop-stalls.ts +14 -6
- 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/progress.ts +154 -0
- package/src/central/teams/routes.ts +2 -2
- package/src/central/teams/store.ts +21 -5
- package/src/central/teams/validation.ts +17 -6
- package/src/central/workspaces/coordinator.ts +3 -1
- package/src/spoke/hub-tool.ts +26 -11
- package/web/dist/assets/{ArtifactPage-C5MMqXLN.js → ArtifactPage-TbFJi59n.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CXSCmhyJ.js → ArtifactPane-7D-M-Ich.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-S0RxNGWy.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
- package/web/dist/assets/{Diagram-BGmyRb-R.js → Diagram-PvOPetOI.js} +1 -1
- package/web/dist/assets/{Library-CFmMbXEa.js → Library-Cdi83pOV.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-7jpOc2HM.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/index-DA0gKIzv.js +33 -0
- 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/{renderer2d-B2Nh39UN.js → renderer2d-CsZnF3_o.js} +1 -1
- package/web/dist/assets/{renderer3d-CTYbljHH.js → renderer3d-BIMA0wFn.js} +2 -2
- package/web/dist/assets/{safe-area-CkhEclGo.js → safe-area-dtrnw9NF.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/assets/TeamsPage-C1NGrUR-.js +0 -1
- package/web/dist/assets/Watchtower-DnvlJaU0.js +0 -2
- package/web/dist/assets/index-Da8SKXMw.js +0 -33
- 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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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-DlQEYmDl.js";import{it as s,rt as c}from"./index-
|
|
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};
|