@huynle/hub 0.7.4 → 0.8.0
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 +217 -0
- package/Dockerfile +7 -2
- package/README.md +148 -19
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- 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/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +632 -49
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// Side-chat routes on /api/agent (HWA-13; contract: Workspaces in docs/plans/2026-10-30-hub-workspaces-artifacts-
|
|
2
|
+
// contracts.md and its HWA-06 clarification; records and snapshot rules: ./sidechats.ts):
|
|
3
|
+
//
|
|
4
|
+
// POST /workspaces/:id/side-chats {sourceSessionID, sources?, bindingID?, harness?, model?, agent?}
|
|
5
|
+
// Idempotency-Key → 201 {sideChat} manage + S + M (+ launch)
|
|
6
|
+
// GET /workspaces/:id/side-chats?limit&cursor → {items, cursor?} (summaries, no quoted text) read + S
|
|
7
|
+
// GET /workspaces/:id/side-chats/:chat → {sideChat} (with the snapshot) read + S + M
|
|
8
|
+
// GET /side-chats/:chat → {sideChat} (for panes, which know only the ID) read + S + M
|
|
9
|
+
// POST /workspaces/:id/side-chats/:chat/refresh {expectedRevision} → {sideChat, delivery} manage + S + M
|
|
10
|
+
// POST /workspaces/:id/side-chats/:chat/send {text, confirmed: true} → 201 {sideChatID, sent} users only
|
|
11
|
+
//
|
|
12
|
+
// Who: users (their own records; another user's is not_found). Apps by workspaces:read/manage plus sessions:read
|
|
13
|
+
// (S) and, where transcript text is copied or shown, messages:read (M). Session tokens are refused (membership
|
|
14
|
+
// grants nothing). Nothing here adds authority: the side chat acts through the EXISTING routes as the caller —
|
|
15
|
+
// - the source transcript through GET /sessions/:addr/log (its tenancy and scope),
|
|
16
|
+
// - the side-chat session through POST /launches (scope sessions:launch, the launch grant on the repo, the kill
|
|
17
|
+
// switch, the launch caps and audit: an app needs all of them, exactly as when it launches directly),
|
|
18
|
+
// - context refreshes to the side-chat session and "Send to main" through POST /msg: a plain msg (never urgent)
|
|
19
|
+
// through the normal message path. It never interrupts a running turn (a busy session gets it queued, after
|
|
20
|
+
// the turn); the first msg to an idle session right after the hub's primer may be delivered as "steer", sharing
|
|
21
|
+
// the primer's model call (delivery quirk hzmcqwpj).
|
|
22
|
+
//
|
|
23
|
+
// Placement is the workspace's host repo binding (bindingID, or the only one): a `checkout` launch of that repo
|
|
24
|
+
// on that machine, placement `require`, `end.when: never`. Nothing derives from the source session's machine or
|
|
25
|
+
// directory, so the side chat may run on another machine than its source. An unreachable machine shows as a
|
|
26
|
+
// launch that waits (status `launching` with the launch's state and reason); no binding is 422 unsupported
|
|
27
|
+
// {feature: "sideChatHost"}. The new session is a root session: its native parentID/rootID and the workspace's
|
|
28
|
+
// supervision are untouched; the link to the source is the side-chat record only.
|
|
29
|
+
//
|
|
30
|
+
// "Send to main" is users only (the console confirms it; `confirmed: true` is required): apps that may message
|
|
31
|
+
// sessions already use POST /msg themselves.
|
|
32
|
+
import { WORKSPACE_SCOPES } from "../../protocol"
|
|
33
|
+
import { agentAllows } from "../agent/actor"
|
|
34
|
+
import { AGENT_ERROR_STATUS, AgentError, type AgentErrorCode } from "../agent/errors"
|
|
35
|
+
import type { AgentEvents, EventInput } from "../agent/events"
|
|
36
|
+
import { idempotencyKeyOf, RouteIdempotency } from "../agent/idempotency"
|
|
37
|
+
import { launchedBy } from "../agent/launch-routes"
|
|
38
|
+
import type { Launch } from "../agent/launch-store"
|
|
39
|
+
import { TEXT_MAX } from "../agent/messages"
|
|
40
|
+
import type { AgentContext, AgentRoute } from "../agent/routes"
|
|
41
|
+
import type { Clock } from "../backend/types"
|
|
42
|
+
import type { Store } from "../store"
|
|
43
|
+
import { WORKSPACE_ROUTE_IDEMPOTENCY } from "./routes"
|
|
44
|
+
import {
|
|
45
|
+
renderContext,
|
|
46
|
+
sideChatChangedEvent,
|
|
47
|
+
sideChatCreatedEvent,
|
|
48
|
+
sideChatID,
|
|
49
|
+
SideChatStore,
|
|
50
|
+
SNAPSHOT_SCAN_ROWS,
|
|
51
|
+
snapshotOf,
|
|
52
|
+
SOURCE_ID_MAX,
|
|
53
|
+
type StoredSideChat,
|
|
54
|
+
} from "./sidechats"
|
|
55
|
+
import type { WorkspaceStore } from "./store"
|
|
56
|
+
import { AGENT_JSON_BODY_MAX, boundedText } from "../agent/body"
|
|
57
|
+
|
|
58
|
+
const PREFIX = "/api/agent/workspaces"
|
|
59
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
60
|
+
const [READ_SCOPE, MANAGE_SCOPE] = WORKSPACE_SCOPES
|
|
61
|
+
const READ = { scope: READ_SCOPE } as const
|
|
62
|
+
const MANAGE = { scope: MANAGE_SCOPE } as const
|
|
63
|
+
/** Users only. */
|
|
64
|
+
const USERS = {} as const
|
|
65
|
+
const SOURCE_ID = new RegExp(`^[!-~]{1,${SOURCE_ID_MAX}}$`)
|
|
66
|
+
const STRING_FIELD = /^[!-~]{1,200}$/
|
|
67
|
+
|
|
68
|
+
/** The existing routes a side chat goes through, as the caller (routePorts finds them in the route table). */
|
|
69
|
+
export interface SideChatPorts {
|
|
70
|
+
readonly transcript?: AgentRoute
|
|
71
|
+
readonly launch?: AgentRoute
|
|
72
|
+
readonly message?: AgentRoute
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The delegate routes in a route table (GET /sessions/:addr/log, POST /launches, POST /msg). */
|
|
76
|
+
export function routePorts(routes: readonly AgentRoute[]): SideChatPorts {
|
|
77
|
+
const find = (method: string, path: string) => routes.find((r) => r.method === method && r.path === path)
|
|
78
|
+
const transcript = find("GET", "/api/agent/sessions/:addr/log")
|
|
79
|
+
const launch = find("POST", "/api/agent/launches")
|
|
80
|
+
const message = find("POST", "/api/agent/msg")
|
|
81
|
+
return { ...(transcript ? { transcript } : {}), ...(launch ? { launch } : {}), ...(message ? { message } : {}) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What the routes read of the launch engine (tenant-scoped view of the side chat's own launch). */
|
|
85
|
+
export interface SideChatLaunches {
|
|
86
|
+
view(userID: string, id: string): Promise<Launch | undefined>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SideChatRoutesDeps {
|
|
90
|
+
readonly workspaces: Pick<WorkspaceStore, "require" | "bindings" | "state">
|
|
91
|
+
/** The records; one over the workspaces' StateStore when absent. */
|
|
92
|
+
readonly sideChats?: SideChatStore
|
|
93
|
+
readonly ports: SideChatPorts
|
|
94
|
+
/** The side chats' launches (status and the session they started); without it a side chat links never. */
|
|
95
|
+
readonly launches?: SideChatLaunches
|
|
96
|
+
/** When the hub last saw the source session change (its catalog), for the stale marker; unknown when absent. */
|
|
97
|
+
readonly sourceUpdatedAt?: (userID: string, sessionID: string) => number | undefined
|
|
98
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
99
|
+
readonly audit?: Pick<Store, "audit">
|
|
100
|
+
readonly idempotency?: RouteIdempotency
|
|
101
|
+
readonly now?: Clock
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ok = (body: unknown, status = 200) => Response.json(body, { status, headers: NO_STORE })
|
|
105
|
+
|
|
106
|
+
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
107
|
+
const text = await boundedText(request, AGENT_JSON_BODY_MAX)
|
|
108
|
+
const raw = JSON.parse(text === "" ? "{}" : text) as unknown
|
|
109
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
110
|
+
return raw as Record<string, unknown>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function only(body: Record<string, unknown>, allowed: readonly string[]): void {
|
|
114
|
+
for (const key of Object.keys(body)) if (!allowed.includes(key)) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "(unprintable)"}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function expected(value: unknown): number {
|
|
118
|
+
if (value === undefined || value === null) throw new AgentError("invalid", "expectedRevision is required: the revision you read", { hint: "Read the side chat and send its revision" })
|
|
119
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new AgentError("invalid", "expectedRevision must be the revision you read")
|
|
120
|
+
return value
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function optionalString(body: Record<string, unknown>, key: string): string | undefined {
|
|
124
|
+
const value = body[key]
|
|
125
|
+
if (value === undefined) return undefined
|
|
126
|
+
if (typeof value !== "string" || !STRING_FIELD.test(value)) throw new AgentError("invalid", `${key} must be 1–200 visible characters`)
|
|
127
|
+
return value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Apps: scopes beyond the route's own (the board's and members' rule, plus messages:read for copied text). */
|
|
131
|
+
function appNeeds(context: AgentContext, scopes: readonly string[]): void {
|
|
132
|
+
const { actor } = context
|
|
133
|
+
if (actor.kind !== "app") return
|
|
134
|
+
const missing = scopes.filter((s) => !actor.scopes.includes(s))
|
|
135
|
+
if (missing.length) throw new AgentError("forbidden", `Side chats also need scope ${missing.join(" and ")}`)
|
|
136
|
+
}
|
|
137
|
+
const SHOWS_SESSIONS = ["sessions:read"]
|
|
138
|
+
const COPIES_TEXT = ["sessions:read", "messages:read"]
|
|
139
|
+
|
|
140
|
+
/** Calls an existing /api/agent route as the caller; its refusals and errors are the side chat's. */
|
|
141
|
+
async function delegate(route: AgentRoute | undefined, feature: string, context: AgentContext, call: { readonly params: Record<string, string>; readonly search?: string; readonly body?: unknown }): Promise<Record<string, unknown>> {
|
|
142
|
+
if (!route) throw new AgentError("unsupported", `This hub cannot ${feature} for side chats`, { extra: { feature } })
|
|
143
|
+
if (!agentAllows(route.allow, context.actor)) throw new AgentError("forbidden", `This credential cannot use ${route.method} ${route.path} (needed to ${feature})`)
|
|
144
|
+
const path = route.path.replace(/:([a-z]+)/g, (_, key: string) => encodeURIComponent(call.params[key] ?? ""))
|
|
145
|
+
const url = new URL(`${path}${call.search ?? ""}`, context.url)
|
|
146
|
+
const request = new Request(url, {
|
|
147
|
+
method: route.method,
|
|
148
|
+
headers: { "content-type": "application/json" },
|
|
149
|
+
...(call.body !== undefined ? { body: JSON.stringify(call.body) } : {}),
|
|
150
|
+
signal: context.request.signal,
|
|
151
|
+
})
|
|
152
|
+
const response = await route.handle({ ...context, request, url, params: call.params })
|
|
153
|
+
const body = (await response.json().catch(() => undefined)) as Record<string, unknown> | undefined
|
|
154
|
+
if (response.ok && body) return body
|
|
155
|
+
const { code, message, hint, ...extra } = (body?.error ?? {}) as Record<string, unknown>
|
|
156
|
+
const known = typeof code === "string" && code in AGENT_ERROR_STATUS
|
|
157
|
+
throw new AgentError(known ? (code as AgentErrorCode) : "internal", typeof message === "string" ? message : `Could not ${feature}`, { ...(typeof hint === "string" ? { hint } : {}), extra })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface SourceInput {
|
|
161
|
+
readonly ids: string[]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sourcesOf(value: unknown, max: number): SourceInput {
|
|
165
|
+
if (value === undefined) return { ids: [] }
|
|
166
|
+
if (!Array.isArray(value)) throw new AgentError("invalid", "sources must be a list of {kind, id}")
|
|
167
|
+
if (value.length > max) throw new AgentError("invalid", `A side chat quotes at most ${max} sources`, { extra: { limit: max } })
|
|
168
|
+
const ids: string[] = []
|
|
169
|
+
let artifact = false
|
|
170
|
+
for (const source of value) {
|
|
171
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) throw new AgentError("invalid", "sources must be a list of {kind, id}")
|
|
172
|
+
const { kind, id, ...rest } = source as Record<string, unknown>
|
|
173
|
+
if (Object.keys(rest).length) throw new AgentError("invalid", "A source is {kind, id}")
|
|
174
|
+
if (kind !== "message" && kind !== "artifact_revision") throw new AgentError("invalid", "A source kind is message or artifact_revision")
|
|
175
|
+
if (typeof id !== "string" || !SOURCE_ID.test(id)) throw new AgentError("invalid", `A source id is 1–${SOURCE_ID_MAX} visible characters`)
|
|
176
|
+
if (kind === "artifact_revision") artifact = true
|
|
177
|
+
if (ids.includes(id)) throw new AgentError("invalid", `Source ${id} is listed twice`)
|
|
178
|
+
ids.push(id)
|
|
179
|
+
}
|
|
180
|
+
if (artifact) throw new AgentError("unsupported", "Side chats quote session messages only in this version; artifact revisions are not supported yet", { extra: { feature: "sideChatArtifactSources" } })
|
|
181
|
+
return { ids }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type Status = "launching" | "ready" | "failed" | "unknown"
|
|
185
|
+
|
|
186
|
+
export function sideChatRoutes(deps: SideChatRoutesDeps): AgentRoute[] {
|
|
187
|
+
const { workspaces, ports } = deps
|
|
188
|
+
const now = deps.now ?? Date.now
|
|
189
|
+
const sideChats = deps.sideChats ?? new SideChatStore({ state: workspaces.state, now })
|
|
190
|
+
const idempotency = deps.idempotency ?? new RouteIdempotency({ state: workspaces.state, collection: WORKSPACE_ROUTE_IDEMPOTENCY, now })
|
|
191
|
+
const limits = sideChats.limits
|
|
192
|
+
|
|
193
|
+
const report = (cause: unknown) => console.error(`[hub] side-chat event failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
194
|
+
async function changed(context: AgentContext, action: string, target: string, ...events: EventInput[]): Promise<void> {
|
|
195
|
+
await deps.audit?.audit({ actor: context.user.id, action, target, detail: context.actor.kind })
|
|
196
|
+
for (const event of events) await deps.events?.append(context.user.id, event).catch(report)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The source's newest transcript rows, read as the caller; the session ID the address resolved to. */
|
|
200
|
+
async function transcript(context: AgentContext, source: string): Promise<{ sessionID: string; rows: unknown[] }> {
|
|
201
|
+
const body = await delegate(ports.transcript, "read the source transcript", context, { params: { addr: source }, search: `?n=${SNAPSHOT_SCAN_ROWS}` })
|
|
202
|
+
return { sessionID: typeof body.sessionID === "string" ? body.sessionID : source, rows: Array.isArray(body.rows) ? body.rows : [] }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const send = (context: AgentContext, to: string, text: string) => delegate(ports.message, "send a message", context, { params: {}, body: { to, text } })
|
|
206
|
+
|
|
207
|
+
/** Links the side-chat session once its launch reports it; the record and its launch (when still known). */
|
|
208
|
+
async function resolved(context: AgentContext, chat: StoredSideChat): Promise<{ chat: StoredSideChat; launch?: Launch }> {
|
|
209
|
+
const launch = await deps.launches?.view(context.user.id, chat.launchID)
|
|
210
|
+
if (chat.sessionID || !launch?.sessionID) return { chat, ...(launch ? { launch } : {}) }
|
|
211
|
+
const linked = await sideChats.link(context.user.id, chat.id, launch.sessionID)
|
|
212
|
+
if (linked) await changed(context, "workspace.sidechat.link", chat.id, sideChatChangedEvent(linked, "sidechat_linked"))
|
|
213
|
+
return { chat: linked ?? (await sideChats.get(context.user.id, chat.id)) ?? chat, launch }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function view(context: AgentContext, chat: StoredSideChat, launch: Launch | undefined, full: boolean) {
|
|
217
|
+
const status: Status = chat.sessionID ? "ready" : launch ? (launch.state === "ended" ? "failed" : "launching") : deps.launches ? "failed" : "unknown"
|
|
218
|
+
const updatedAt = deps.sourceUpdatedAt?.(context.user.id, chat.sourceSessionID)
|
|
219
|
+
const { snapshot, ...rest } = chat
|
|
220
|
+
const summary = { generation: snapshot.generation, takenAt: snapshot.takenAt, mode: snapshot.mode, count: snapshot.items.length, bytes: snapshot.bytes, omitted: snapshot.omitted, ...(snapshot.sourceNewestAt !== undefined ? { sourceNewestAt: snapshot.sourceNewestAt } : {}) }
|
|
221
|
+
return {
|
|
222
|
+
...rest,
|
|
223
|
+
snapshot: full ? snapshot : summary,
|
|
224
|
+
status,
|
|
225
|
+
...(launch
|
|
226
|
+
? { launch: { id: launch.id, state: launch.state, ...(launch.outcome ? { outcome: launch.outcome } : {}), ...(context.actor.kind === "user" || launch.by === launchedBy(context.actor) ? { reason: launch.reason } : {}) } }
|
|
227
|
+
: {}),
|
|
228
|
+
...(updatedAt !== undefined ? { sourceUpdatedAt: updatedAt, stale: updatedAt > snapshot.takenAt } : {}),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function active(context: AgentContext, workspaceID: string) {
|
|
233
|
+
const workspace = await workspaces.require(context.user.id, workspaceID)
|
|
234
|
+
if (workspace.state === "archived") throw new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
|
|
235
|
+
return workspace
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function bindingFor(userID: string, workspaceID: string, bindingID: string | undefined) {
|
|
239
|
+
const bindings = await workspaces.bindings(userID, workspaceID)
|
|
240
|
+
if (bindingID !== undefined) {
|
|
241
|
+
const binding = bindings.find((b) => b.id === bindingID)
|
|
242
|
+
if (!binding) throw new AgentError("not_found", `No binding ${bindingID} in workspace ${workspaceID}`)
|
|
243
|
+
return binding
|
|
244
|
+
}
|
|
245
|
+
if (bindings.length === 0) {
|
|
246
|
+
throw new AgentError("unsupported", "No machine can host a side chat for this workspace: it has no host repository binding", { hint: "Bind a repository on a machine first (POST /workspaces/:id/bindings)", extra: { feature: "sideChatHost" } })
|
|
247
|
+
}
|
|
248
|
+
if (bindings.length > 1) throw new AgentError("invalid", `bindingID is required: the workspace has ${bindings.length} host repository bindings`)
|
|
249
|
+
return bindings[0]!
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function createSideChat(context: AgentContext, workspaceID: string, body: Record<string, unknown>, key: string | undefined) {
|
|
253
|
+
const { user } = context
|
|
254
|
+
const source = typeof body.sourceSessionID === "string" && SOURCE_ID.test(body.sourceSessionID) ? body.sourceSessionID : undefined
|
|
255
|
+
if (!source) throw new AgentError("invalid", "sourceSessionID must be the source session's ID or address")
|
|
256
|
+
const { ids } = sourcesOf(body.sources, limits.sideChatContextItems)
|
|
257
|
+
const harness = optionalString(body, "harness") ?? "opencode"
|
|
258
|
+
const model = optionalString(body, "model")
|
|
259
|
+
const agent = optionalString(body, "agent")
|
|
260
|
+
await active(context, workspaceID)
|
|
261
|
+
const binding = await bindingFor(user.id, workspaceID, optionalString(body, "bindingID"))
|
|
262
|
+
const id = sideChatID(key === undefined ? undefined : `${user.id}\n${workspaceID}\n${key}`)
|
|
263
|
+
await sideChats.reserve(user.id, workspaceID, id)
|
|
264
|
+
try {
|
|
265
|
+
const read = await transcript(context, source)
|
|
266
|
+
const info = { sideChatID: id, workspaceID, sourceSessionID: read.sessionID }
|
|
267
|
+
const snapshot = snapshotOf(read.rows, { ids, limits }, info, now())
|
|
268
|
+
const launched = await delegate(ports.launch, "launch the side-chat session", context, {
|
|
269
|
+
params: {},
|
|
270
|
+
body: {
|
|
271
|
+
key: `sidechat:${id}`,
|
|
272
|
+
harness,
|
|
273
|
+
...(model ? { model } : {}),
|
|
274
|
+
...(agent ? { agent } : {}),
|
|
275
|
+
workspace: { repo: binding.repo, mode: "checkout" },
|
|
276
|
+
placement: { machine: binding.machineID, mode: "require" },
|
|
277
|
+
prompt: renderContext(info, snapshot),
|
|
278
|
+
labels: { "hub.sidechat": id, "hub.workspace": workspaceID },
|
|
279
|
+
end: { when: "never", cleanup: "keep" },
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
const launch = launched.launch as { id: string; sessionID?: string }
|
|
283
|
+
const { sideChat, created } = await sideChats.insert(user.id, {
|
|
284
|
+
id,
|
|
285
|
+
workspaceID,
|
|
286
|
+
sourceSessionID: read.sessionID,
|
|
287
|
+
launchID: launch.id,
|
|
288
|
+
...(launch.sessionID ? { sessionID: launch.sessionID } : {}),
|
|
289
|
+
by: launchedBy(context.actor),
|
|
290
|
+
bindingID: binding.id,
|
|
291
|
+
machineID: binding.machineID,
|
|
292
|
+
harness,
|
|
293
|
+
snapshot,
|
|
294
|
+
})
|
|
295
|
+
if (created) await changed(context, "workspace.sidechat.create", sideChat.id, sideChatCreatedEvent(sideChat))
|
|
296
|
+
const current = await resolved(context, sideChat)
|
|
297
|
+
return { status: created ? 201 : 200, body: { sideChat: view(context, current.chat, current.launch, true) } }
|
|
298
|
+
} catch (cause) {
|
|
299
|
+
await sideChats.release(user.id, workspaceID, id).catch(report)
|
|
300
|
+
throw cause
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const ws = (context: AgentContext) => context.params.id!
|
|
305
|
+
|
|
306
|
+
async function readOne(context: AgentContext, workspaceID: string | undefined) {
|
|
307
|
+
appNeeds(context, COPIES_TEXT)
|
|
308
|
+
if (workspaceID !== undefined) await workspaces.require(context.user.id, workspaceID)
|
|
309
|
+
const chat = await sideChats.require(context.user.id, workspaceID, context.params.chat!)
|
|
310
|
+
const current = await resolved(context, chat)
|
|
311
|
+
return ok({ sideChat: view(context, current.chat, current.launch, true) })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return [
|
|
315
|
+
{
|
|
316
|
+
method: "POST",
|
|
317
|
+
path: `${PREFIX}/:id/side-chats`,
|
|
318
|
+
allow: MANAGE,
|
|
319
|
+
async handle(context) {
|
|
320
|
+
appNeeds(context, COPIES_TEXT)
|
|
321
|
+
const body = await bodyOf(context.request)
|
|
322
|
+
only(body, ["sourceSessionID", "sources", "bindingID", "harness", "model", "agent"])
|
|
323
|
+
const key = idempotencyKeyOf(context.request)
|
|
324
|
+
return idempotency.run(context.user.id, `POST ${context.url.pathname}`, key ?? null, body, () => createSideChat(context, ws(context), body, key))
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
method: "GET",
|
|
329
|
+
path: `${PREFIX}/:id/side-chats`,
|
|
330
|
+
allow: READ,
|
|
331
|
+
async handle(context) {
|
|
332
|
+
appNeeds(context, SHOWS_SESSIONS)
|
|
333
|
+
await workspaces.require(context.user.id, ws(context))
|
|
334
|
+
const raw = context.url.searchParams.get("limit")
|
|
335
|
+
const limit = raw === null ? undefined : /^[0-9]{1,6}$/.test(raw) ? Number(raw) : Number.NaN
|
|
336
|
+
const cursor = context.url.searchParams.get("cursor") || undefined
|
|
337
|
+
const page = await sideChats.list(context.user.id, ws(context), { ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}) })
|
|
338
|
+
const items = []
|
|
339
|
+
for (const chat of page.items) {
|
|
340
|
+
const current = await resolved(context, chat)
|
|
341
|
+
items.push(view(context, current.chat, current.launch, false))
|
|
342
|
+
}
|
|
343
|
+
return ok({ items, ...(page.cursor ? { cursor: page.cursor } : {}) })
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
method: "GET",
|
|
348
|
+
path: `${PREFIX}/:id/side-chats/:chat`,
|
|
349
|
+
allow: READ,
|
|
350
|
+
handle: (context) => readOne(context, ws(context)),
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
method: "GET",
|
|
354
|
+
path: "/api/agent/side-chats/:chat",
|
|
355
|
+
allow: READ,
|
|
356
|
+
handle: (context) => readOne(context, undefined),
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
method: "POST",
|
|
360
|
+
path: `${PREFIX}/:id/side-chats/:chat/refresh`,
|
|
361
|
+
allow: MANAGE,
|
|
362
|
+
async handle(context) {
|
|
363
|
+
appNeeds(context, COPIES_TEXT)
|
|
364
|
+
const body = await bodyOf(context.request)
|
|
365
|
+
only(body, ["expectedRevision"])
|
|
366
|
+
const revision = expected(body.expectedRevision)
|
|
367
|
+
const { user } = context
|
|
368
|
+
await active(context, ws(context))
|
|
369
|
+
const chat = await sideChats.require(user.id, ws(context), context.params.chat!)
|
|
370
|
+
if (chat.revision !== revision) throw new AgentError("conflict", "The side chat changed since you read it", { extra: { currentRevision: chat.revision } })
|
|
371
|
+
const read = await transcript(context, chat.sourceSessionID)
|
|
372
|
+
const info = { sideChatID: chat.id, workspaceID: chat.workspaceID, sourceSessionID: chat.sourceSessionID }
|
|
373
|
+
const ids = chat.snapshot.mode === "selected" ? chat.sources.map((s) => s.id) : []
|
|
374
|
+
const snapshot = snapshotOf(read.rows, { ids, limits, previous: chat.snapshot }, info, now())
|
|
375
|
+
const refreshed = await sideChats.refresh(user.id, ws(context), chat.id, revision, snapshot)
|
|
376
|
+
await changed(context, "workspace.sidechat.refresh", chat.id, sideChatChangedEvent(refreshed, "sidechat_refreshed"))
|
|
377
|
+
const current = await resolved(context, refreshed)
|
|
378
|
+
let delivery: Record<string, unknown> = { status: "not_started" }
|
|
379
|
+
if (current.chat.sessionID) {
|
|
380
|
+
try {
|
|
381
|
+
const sent = await send(context, current.chat.sessionID, renderContext(info, snapshot))
|
|
382
|
+
delivery = { status: "sent", ...sent }
|
|
383
|
+
} catch (cause) {
|
|
384
|
+
if (!(cause instanceof AgentError)) throw cause
|
|
385
|
+
delivery = { status: "failed", code: cause.code, message: cause.message }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return ok({ sideChat: view(context, current.chat, current.launch, true), delivery })
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
method: "POST",
|
|
393
|
+
path: `${PREFIX}/:id/side-chats/:chat/send`,
|
|
394
|
+
allow: USERS,
|
|
395
|
+
async handle(context) {
|
|
396
|
+
const body = await bodyOf(context.request)
|
|
397
|
+
only(body, ["text", "confirmed"])
|
|
398
|
+
if (body.confirmed !== true) throw new AgentError("invalid", "Send to main needs confirmed: true (the user confirms the message)")
|
|
399
|
+
const header = `[From side chat ${context.params.chat!}]\n`
|
|
400
|
+
if (typeof body.text !== "string" || !body.text.trim()) throw new AgentError("invalid", "text must be a non-empty string")
|
|
401
|
+
if (header.length + body.text.length > TEXT_MAX) throw new AgentError("invalid", `text is longer than ${TEXT_MAX - header.length} characters`)
|
|
402
|
+
await workspaces.require(context.user.id, ws(context))
|
|
403
|
+
const chat = await sideChats.require(context.user.id, ws(context), context.params.chat!)
|
|
404
|
+
const sent = await send(context, chat.sourceSessionID, `[From side chat ${chat.id}]\n${body.text}`)
|
|
405
|
+
await deps.audit?.audit({ actor: context.user.id, action: "workspace.sidechat.send", target: chat.id, detail: context.actor.kind })
|
|
406
|
+
return ok({ sideChatID: chat.id, sent }, 201)
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
]
|
|
410
|
+
}
|