From 2306eb86db4ed90139a184021f53dfe8b54d1d56 Mon Sep 17 00:00:00 2001 From: hachej Date: Mon, 27 Jul 2026 13:50:39 +0000 Subject: [PATCH 01/72] =?UTF-8?q?docs:=20single=20sequencing=20spine=20?= =?UTF-8?q?=E2=80=94=20DIRECTION.md=20(owner-ratified=202026-07-27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One direction across multi-agent console, streaming, F-graph/Environments, BYOK, MCP, sandbox, v2 remote. Waves with explicit triggers; decision log; plan folders demoted to subordinate detail. Grilled decision-by-decision with the owner before authoring. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NxQWirpWsYV5Y6Tz8Txy5i --- AGENTS.md | 1 + docs/DIRECTION.md | 119 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 docs/DIRECTION.md diff --git a/AGENTS.md b/AGENTS.md index 6a5627c02..042b2a805 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ Detailed coding practices, workflow, architecture, and package docs live under ` | Need | Read | | --- | --- | | Project/package map | [`docs/README.md`](docs/README.md) | +| **Single direction / sequencing spine** | [`docs/DIRECTION.md`](docs/DIRECTION.md) | | Coding rules | [`docs/kanzen/procedures/coding-rules.md`](docs/kanzen/procedures/coding-rules.md) | | Coding invariants | [`docs/kanzen/procedures/coding-invariants.md`](docs/kanzen/procedures/coding-invariants.md) | | Repo commands | [`docs/kanzen/procedures/repo-commands.md`](docs/kanzen/procedures/repo-commands.md) | diff --git a/docs/DIRECTION.md b/docs/DIRECTION.md new file mode 100644 index 000000000..c5f1dcc8a --- /dev/null +++ b/docs/DIRECTION.md @@ -0,0 +1,119 @@ +# DIRECTION — the one spine + +Owner-ratified 2026-07-27. This document is the single source of sequencing +truth for the platform. Every plan folder under `docs/issues/**` is DETAIL, +subordinate to this file: if an issue plan and this file disagree on what +happens next, this file wins until the owner amends it. Orchestrators dispatch +from the waves below — nothing else — regardless of what `br ready` surfaces. + +## Vision (unchanged since #391) + +A workspace where multiple agents — different models, different capabilities, +different costs — work for you in one console: a default deep-work agent +beside cheaper specialists, extensible toward authored agents (agents as +data), external capabilities via MCP, per-workspace keys, and eventually +third-party agents and a marketplace. The long-form vision persona is a +Chief-of-Staff agent managing issues, mail, and the boring-ui Inbox — that is +where this goes, not what we build next. + +How-decisions along the way: Decision 26 (domain-routed workspaces) was +abandoned; Decision 28 (#889: application agent fleets, Workspace +orchestration, transport-neutral Environment service) is current. #909 +delivered Decision 28's first construction segment. + +## Built and released (do not re-plan this) + +v0.1.91: the **AgentGateway v0** engine room — `createAgentHost()` single +construction funnel, `AgentFleetCompiler` fail-fast validation, +`EmbeddedAgentGateway` (frozen 7-method session contract, addressed HTTP +surface per agent), Environment leases, per-agent model policy +(`spec.model.preferred` + strict resolution), all five consumers (workspace, +core, CLI, playground, delegation) composing through it, enforced by CI +invariants. Also shipped: A1 authored-agent groundwork, boring-bash/sandbox +extraction, BYOK credential-injection contract, D1 tenant provisioning. +Authority for what exists: `docs/issues/909/plan.md` §6 (frozen). + +## Wave 1 — NOW: the multi-agent console (beads .27 → .31) + +Goal: **two agents, visibly, in one UI** — `default` plus a dummy second +agent on a cheaper model. Infra only; no persona content. + +1. `wt-391-forward-0jpy.27` (in progress): browser wired to the addressed + gateway routes — addressed reload-reconnect streams, dynamic agent + selection from `GET /api/v1/agents`, full-app opt-in, two-agent fixture, + E2E with a request-route assertion that fails on any legacy-wire use. +2. `wt-391-forward-0jpy.31`: the console UX — agent switcher, per-agent + session grouping, presence, two agents streaming concurrently, switching + without losing an in-flight turn. + +**Done-bar: works in BOTH workspace-playground and full-app** (playground +green ≠ product works — the 0.1.91-era lesson). Supporting hygiene that lands +inside this wave as needed: `.29` (CSS build), `.26` (E2E in CI — minimal +slice absorbed by .27), `.28` (re-land native sessions + rename menu, after +.27), `.32` (full-app dev onboarding), `.33` (release smoke gate). + +## Wave 2 — trigger: Wave 1 demo works + +- **Chat streaming durability** (`0jpy.8` + `26v`, ONE lane): activate the + dormant SqliteEventStreamStore. Trigger is explicit: `.31`'s concurrent + streams + Seneca production chat. This is where Seneca hardening lives. +- **F-graph execution begins** (Decision 28 detail: `docs/issues/391/plan.md`): + F0b inventory → F1/F2 Environment contracts + boring-bash service → onward. + F0a paperwork (the rebased #904 with its three shipped-reality amendments) + is ratified during Wave 1; F1+ execution does NOT start before the Wave 1 + demo exists. + +## Wave 3 — trigger: named consumers, not calendar + +- **BYOK** (KEY0 + parked PR #917): becomes load-bearing when multi-agent + model costs are real. First step is ratifying the de-facto policy the + shipped code implements. +- **External MCP** (#900, re-land per #946: small reviewed slices, + application-owned atomic backend): waits for its consumer — mail/tools for + the CoS persona, or a client need. Do not re-land #937 wholesale. +- **Authored catalog** (`0jpy.9`, includes fleet-time model-ID validation and + maxTokensPerTurn enforcement): when personas become data. + +## Wave 4 — v2 era + +- **Sandbox/SBX1** (own-cloud runsc fleet; parked PR #916): infrastructure + for remote/third-party agents. Trigger restated: after F7 conformance. +- **#905 v2 remote Host** (`0jpy.11`/`.16`): behind three gates — plugin + trust cleanup (`.13`), the model-cap revisit (below), and an owner-recorded + additive-v2 amendment. +- Marketplace-tier lanes (identity, billing, channels, catalog UX) stay + frozen behind their existing owner gates. + +## Decision log (owner, 2026-07-27) + +| Decision | Ruling | +|---|---| +| Direction | Multi-agent console is the product thrust; Seneca is its first consumer, not a separate track | +| Demo fleet | `default` + dummy second agent (different model). CoS/personas = vision, not a current lane | +| Done-bar | Playground AND full-app | +| Model-policy cap (`.30`) | (a) caller's per-prompt model wins. MANDATORY revisit before the v2 remote tracer or BYOK, whichever first | +| F-graph | F0a paperwork now; F1+ execution frozen until the Wave 1 demo | +| Streaming | Enters exactly when multi-agent needs it (Wave 2 trigger), merged into one lane | + +## Standing execution rules (earned this release cycle) + +- Green CI is necessary, never sufficient: every wave's exit includes a real + smoke of built artifacts (`.33` makes this a release gate). +- Tests must fail when the behavior is broken — no assertions on thrown + objects where the client sees a different status; no fixtures that pass on + empty output. +- One heavy executor at a time; independent review before merge; verify + agent claims against `gh`/git ground truth, never against reports. + +## Plan-folder map (what still binds) + +| Folder | Status | +|---|---| +| `docs/issues/909/` | Frozen record of what shipped + follow-up beads. Binding for the Gateway contract (§6) | +| `docs/issues/391/` | Decision-28 detail for Waves 2+. Binding once its wave opens | +| `docs/issues/805/` | A1 shipped; remainder absorbed into 391's F-graph. Reference only | +| `docs/issues/808/`, `820/`, `806/`, `900/` | Lane detail for Waves 3–4. Reference until their trigger fires | + +Bead graph: epic `wt-391-forward-0jpy` follow-ups (Wave 1–2) + F-graph under +`wt-391-forward-step1a-current-xn9` (Wave 2+). Anything not reachable from +this file's waves is not dispatchable without an owner amendment here. From 535d57f9f37b0cc52ffe674602230ebbb5a33044 Mon Sep 17 00:00:00 2001 From: hachej Date: Mon, 27 Jul 2026 15:39:32 +0000 Subject: [PATCH 02/72] #909 fix(agent): make CSS builds CWD-independent --- .../agent/scripts/assert-build-artifacts.mjs | 13 +++++++++- packages/agent/scripts/build-front-css.mjs | 6 +++-- .../styles/__tests__/build-front-css.test.ts | 25 +++++++++++++++++++ .../workspace/scripts/build-workspace-css.mjs | 10 +++++--- .../src/__tests__/build-workspace-css.test.ts | 24 ++++++++++++++++++ 5 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 packages/agent/src/front/styles/__tests__/build-front-css.test.ts create mode 100644 packages/workspace/src/__tests__/build-workspace-css.test.ts diff --git a/packages/agent/scripts/assert-build-artifacts.mjs b/packages/agent/scripts/assert-build-artifacts.mjs index c2c272282..712cead82 100644 --- a/packages/agent/scripts/assert-build-artifacts.mjs +++ b/packages/agent/scripts/assert-build-artifacts.mjs @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs' -import { access } from 'node:fs/promises' +import { access, stat } from 'node:fs/promises' import { constants } from 'node:fs' import path from 'node:path' import { spawnSync } from 'node:child_process' @@ -25,6 +25,7 @@ const requiredFiles = [ 'dist/eval/index.js', 'dist/eval/index.d.ts', ] +const minimumFrontCssBytes = 100_000 function resolveFromPackage(relPath) { return path.resolve(packageRoot, relPath) @@ -54,6 +55,15 @@ async function assertExists(relPath) { await access(resolveFromPackage(relPath), constants.F_OK) } +async function assertMinimumSize(relPath, minimumBytes) { + const { size } = await stat(resolveFromPackage(relPath)) + if (size < minimumBytes) { + throw new Error( + `${relPath} must be at least ${minimumBytes} bytes; got ${size} bytes`, + ) + } +} + function assertNodeParsable(relPath) { const absolutePath = resolveFromPackage(relPath) const checkResult = spawnSync(process.execPath, ['--check', absolutePath], { @@ -195,6 +205,7 @@ async function main() { for (const relPath of requiredFiles) { await assertExists(relPath) } + await assertMinimumSize('dist/front/styles.css', minimumFrontCssBytes) await assertExists(coreEntry.displayPath) await assertExists(serverEntry.displayPath) diff --git a/packages/agent/scripts/build-front-css.mjs b/packages/agent/scripts/build-front-css.mjs index 604175137..9a0699e2e 100644 --- a/packages/agent/scripts/build-front-css.mjs +++ b/packages/agent/scripts/build-front-css.mjs @@ -1,10 +1,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises" import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" import postcss from "postcss" import tailwindcss from "@tailwindcss/postcss" -const input = resolve("src/front/styles/globals.css") -const output = resolve("dist/front/styles.css") +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const input = resolve(packageRoot, "src/front/styles/globals.css") +const output = resolve(packageRoot, "dist/front/styles.css") const css = await readFile(input, "utf8") const result = await postcss([tailwindcss()]).process(css, { diff --git a/packages/agent/src/front/styles/__tests__/build-front-css.test.ts b/packages/agent/src/front/styles/__tests__/build-front-css.test.ts new file mode 100644 index 000000000..7874a4991 --- /dev/null +++ b/packages/agent/src/front/styles/__tests__/build-front-css.test.ts @@ -0,0 +1,25 @@ +import { execFileSync } from "node:child_process" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import path from "node:path" +import { describe, expect, it } from "vitest" + +const packageRoot = fileURLToPath(new URL("../../../../", import.meta.url)) +const repoRoot = path.resolve(packageRoot, "../..") +const buildScript = path.resolve(packageRoot, "scripts/build-front-css.mjs") +const output = path.resolve(packageRoot, "dist/front/styles.css") + +function buildFrom(cwd: string) { + execFileSync(process.execPath, [buildScript], { cwd }) + return readFileSync(output) +} + +describe("build-front-css", () => { + it("produces the same complete stylesheet from the repo root and package directory", () => { + const fromRepoRoot = buildFrom(repoRoot) + const fromPackageRoot = buildFrom(packageRoot) + + expect(fromRepoRoot.byteLength).toBeGreaterThanOrEqual(100_000) + expect(fromPackageRoot).toEqual(fromRepoRoot) + }) +}) diff --git a/packages/workspace/scripts/build-workspace-css.mjs b/packages/workspace/scripts/build-workspace-css.mjs index e2cefe315..36f4f098d 100644 --- a/packages/workspace/scripts/build-workspace-css.mjs +++ b/packages/workspace/scripts/build-workspace-css.mjs @@ -1,16 +1,18 @@ import { createRequire } from "node:module" import { mkdir, readFile, writeFile } from "node:fs/promises" import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" import postcss from "postcss" import tailwindcss from "@tailwindcss/postcss" const require = createRequire(import.meta.url) -const globalsInput = resolve("src/globals.css") +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const globalsInput = resolve(packageRoot, "src/globals.css") const dockviewCssPath = require.resolve("dockview-react/dist/styles/dockview.css") -const dockviewOverridesPath = resolve("src/front/dock/dockview-overrides.css") -const chatPaneStagePath = resolve("src/front/layout/chat-pane-stage.css") -const output = resolve("dist/workspace.css") +const dockviewOverridesPath = resolve(packageRoot, "src/front/dock/dockview-overrides.css") +const chatPaneStagePath = resolve(packageRoot, "src/front/layout/chat-pane-stage.css") +const output = resolve(packageRoot, "dist/workspace.css") const [dockviewCss, dockviewOverridesCss, chatPaneStageCss, globalsCss] = await Promise.all([ readFile(dockviewCssPath, "utf8"), diff --git a/packages/workspace/src/__tests__/build-workspace-css.test.ts b/packages/workspace/src/__tests__/build-workspace-css.test.ts new file mode 100644 index 000000000..d23a4f3d4 --- /dev/null +++ b/packages/workspace/src/__tests__/build-workspace-css.test.ts @@ -0,0 +1,24 @@ +import { execFileSync } from "node:child_process" +import { readFileSync } from "node:fs" +import path from "node:path" +import { describe, expect, it } from "vitest" + +const packageRoot = process.cwd() +const repoRoot = path.resolve(packageRoot, "../..") +const buildScript = path.resolve(packageRoot, "scripts/build-workspace-css.mjs") +const output = path.resolve(packageRoot, "dist/workspace.css") + +function buildFrom(cwd: string) { + execFileSync(process.execPath, [buildScript], { cwd }) + return readFileSync(output) +} + +describe("build-workspace-css", () => { + it("produces the same complete stylesheet from the repo root and package directory", () => { + const fromRepoRoot = buildFrom(repoRoot) + const fromPackageRoot = buildFrom(packageRoot) + + expect(fromRepoRoot.byteLength).toBeGreaterThanOrEqual(100_000) + expect(fromPackageRoot).toEqual(fromRepoRoot) + }) +}) From 0863c18fb5b028a31c9532b9a6a1354612b4ddfa Mon Sep 17 00:00:00 2001 From: hachej Date: Mon, 27 Jul 2026 13:30:41 +0000 Subject: [PATCH 03/72] #909 feat(front): keep reload reconnect streams addressed --- .../chat/pi/__tests__/piChatStream.test.ts | 19 ++++++++++++- .../chat/pi/__tests__/remotePiSession.test.ts | 28 +++++++++++++++++++ .../agent/src/front/chat/pi/piChatStream.ts | 9 ++++-- .../src/front/chat/pi/remotePiSession.ts | 10 ++++--- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/front/chat/pi/__tests__/piChatStream.test.ts b/packages/agent/src/front/chat/pi/__tests__/piChatStream.test.ts index e044b7c9d..a66a53e14 100644 --- a/packages/agent/src/front/chat/pi/__tests__/piChatStream.test.ts +++ b/packages/agent/src/front/chat/pi/__tests__/piChatStream.test.ts @@ -184,7 +184,7 @@ describe('replay/gap recovery helpers', () => { expect(parsePiChatReplayRangeError(416, { error: { code: ErrorCode.enum.CURSOR_OUT_OF_RANGE, details: { latestSeq: 44 } } })).toBeNull() }) - it('builds active-reload reconnect URLs from /state seq without browser transcript cache', () => { + it('builds legacy active-reload reconnect URLs from /state seq when agentTypeId is absent', () => { const snapshot = activeSnapshot(37) expect(buildReloadReconnectPlan(snapshot, 'https://boring.test/')).toEqual({ @@ -196,6 +196,23 @@ describe('replay/gap recovery helpers', () => { '/api/v1/agent/pi-chat/pi%20session%2Factive/events?cursor=37', ) }) + + it('builds addressed active-reload reconnect URLs when agentTypeId is present', () => { + const snapshot = activeSnapshot(37) + + expect(buildReloadReconnectPlan(snapshot, 'https://boring.test/', 'review/agent')).toEqual({ + sessionId: 'pi session/active', + cursor: 37, + eventsUrl: 'https://boring.test/api/v1/agents/review%2Fagent/sessions/pi%20session%2Factive/events?cursor=37', + }) + expect(buildPiChatEventsUrl({ + agentTypeId: 'review/agent', + sessionId: snapshot.sessionId, + cursor: snapshot.seq, + })).toBe( + '/api/v1/agents/review%2Fagent/sessions/pi%20session%2Factive/events?cursor=37', + ) + }) }) describe('calculateJitteredBackoffDelayMs', () => { diff --git a/packages/agent/src/front/chat/pi/__tests__/remotePiSession.test.ts b/packages/agent/src/front/chat/pi/__tests__/remotePiSession.test.ts index 98281908c..2e5501e16 100644 --- a/packages/agent/src/front/chat/pi/__tests__/remotePiSession.test.ts +++ b/packages/agent/src/front/chat/pi/__tests__/remotePiSession.test.ts @@ -155,6 +155,34 @@ describe('RemotePiSession', () => { session.dispose() }) + it('keeps addressed active-reload hydration and reconnect off the legacy route', async () => { + const reloadEvents = openNdjsonStream() + const reconnectEvents = openNdjsonStream() + let eventCalls = 0 + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/state')) return jsonResponse({ state: snapshot({ seq: 42 }) }) + if (url.endsWith('/events?cursor=42')) { + eventCalls += 1 + return new Response(eventCalls === 1 ? reloadEvents.stream : reconnectEvents.stream) + } + throw new Error(`unexpected URL ${url}`) + }) as unknown as MockFetch + const session = createSession(fetchMock, { agentTypeId: 'review/agent' }) + + await waitUntil(() => fetchMock.mock.calls.length >= 2) + reloadEvents.close() + await waitUntil(() => eventCalls === 2) + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://agent.test/api/v1/agents/review%2Fagent/sessions/s1/state', + 'https://agent.test/api/v1/agents/review%2Fagent/sessions/s1/events?cursor=42', + 'https://agent.test/api/v1/agents/review%2Fagent/sessions/s1/events?cursor=42', + ]) + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/api/v1/agent/pi-chat/'))).toBe(false) + + session.dispose() + }) + it('silently reconnects after a hung event stream connect times out', async () => { const events = openNdjsonStream() let eventCalls = 0 diff --git a/packages/agent/src/front/chat/pi/piChatStream.ts b/packages/agent/src/front/chat/pi/piChatStream.ts index aac75a9f8..7f9bdf40a 100644 --- a/packages/agent/src/front/chat/pi/piChatStream.ts +++ b/packages/agent/src/front/chat/pi/piChatStream.ts @@ -188,22 +188,27 @@ export function replayRangeErrorToRecovery(error: PiChatReplayRangeError): PiCha export function buildPiChatEventsUrl({ apiBaseUrl = '', + agentTypeId, sessionId, cursor, }: { apiBaseUrl?: string + agentTypeId?: string sessionId: string cursor: number }): string { const base = apiBaseUrl.replace(/\/$/, '') + if (agentTypeId) { + return `${base}/api/v1/agents/${encodeURIComponent(agentTypeId)}/sessions/${encodeURIComponent(sessionId)}/events?cursor=${encodeURIComponent(String(cursor))}` + } return `${base}/api/v1/agent/pi-chat/${encodeURIComponent(sessionId)}/events?cursor=${encodeURIComponent(String(cursor))}` } -export function buildReloadReconnectPlan(snapshot: PiChatSnapshot, apiBaseUrl?: string) { +export function buildReloadReconnectPlan(snapshot: PiChatSnapshot, apiBaseUrl?: string, agentTypeId?: string) { return { sessionId: snapshot.sessionId, cursor: snapshot.seq, - eventsUrl: buildPiChatEventsUrl({ apiBaseUrl, sessionId: snapshot.sessionId, cursor: snapshot.seq }), + eventsUrl: buildPiChatEventsUrl({ apiBaseUrl, agentTypeId, sessionId: snapshot.sessionId, cursor: snapshot.seq }), } } diff --git a/packages/agent/src/front/chat/pi/remotePiSession.ts b/packages/agent/src/front/chat/pi/remotePiSession.ts index 917e3772d..6c7d1c47c 100644 --- a/packages/agent/src/front/chat/pi/remotePiSession.ts +++ b/packages/agent/src/front/chat/pi/remotePiSession.ts @@ -558,10 +558,12 @@ export class RemotePiSession { } private eventsUrl(cursor: number): string { - if (!this.options.agentTypeId) { - return buildPiChatEventsUrl({ apiBaseUrl: this.apiBaseUrl, sessionId: this.options.sessionId, cursor }) - } - return `${this.sessionUrl('/events')}?cursor=${encodeURIComponent(String(cursor))}` + return buildPiChatEventsUrl({ + apiBaseUrl: this.apiBaseUrl, + agentTypeId: this.options.agentTypeId, + sessionId: this.options.sessionId, + cursor, + }) } private sessionUrl(path: string): string { From da608fc785bc771c158dd270239bc7bd75cd7394 Mon Sep 17 00:00:00 2001 From: hachej Date: Mon, 27 Jul 2026 14:02:55 +0000 Subject: [PATCH 04/72] #909 feat(front): select addressed agents dynamically --- apps/workspace-playground/src/front/App.tsx | 4 +- .../src/front/__tests__/publicExports.test.ts | 6 + packages/agent/src/front/chat/PiChatPanel.tsx | 97 ++++- .../front/chat/__tests__/PiChatPanel.test.tsx | 215 ++++++++++ .../piChatPanelHooks.addressed.test.tsx | 38 ++ .../useAddressedAgentSelection.test.tsx | 61 +++ .../chat/components/AgentSelectionControl.tsx | 39 ++ .../agent/src/front/chat/piChatPanelHooks.ts | 24 +- .../usePiSessions.addressed.test.tsx | 216 ++++++++++ .../src/front/chat/session/usePiSessions.ts | 51 ++- .../front/chat/useAddressedAgentSelection.ts | 179 +++++++++ packages/agent/src/front/index.ts | 9 + .../src/app/front/WorkspaceAgentFront.tsx | 271 +++++++++---- .../__tests__/WorkspaceAgentFront.test.tsx | 370 +++++++++++++++++- packages/workspace/src/app/front/index.ts | 3 + 15 files changed, 1469 insertions(+), 114 deletions(-) create mode 100644 packages/agent/src/front/chat/__tests__/piChatPanelHooks.addressed.test.tsx create mode 100644 packages/agent/src/front/chat/__tests__/useAddressedAgentSelection.test.tsx create mode 100644 packages/agent/src/front/chat/components/AgentSelectionControl.tsx create mode 100644 packages/agent/src/front/chat/useAddressedAgentSelection.ts diff --git a/apps/workspace-playground/src/front/App.tsx b/apps/workspace-playground/src/front/App.tsx index 4af15ec29..3a78dedd9 100644 --- a/apps/workspace-playground/src/front/App.tsx +++ b/apps/workspace-playground/src/front/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react" +import { useAddressedAgentSelection } from "@hachej/boring-agent/front" import { createDeckPlugin } from "@hachej/boring-deck/front" import type { DeckWidgetDefinition } from "@hachej/boring-deck/shared" import { FileTreePane, WorkspaceProvider } from "@hachej/boring-workspace" @@ -210,7 +211,8 @@ export function WorkspaceShell() { return ( { expect(source).toMatch(/usePiSessions|UsePiSessionsOptions/) }) + test('exports the addressed agent selection seam for workspace-level opt in', () => { + const source = frontIndex() + expect(source).toMatch(/useAddressedAgentSelection|UseAddressedAgentSelectionOptions/) + expect(source).toMatch(/AgentSelectionControl|AgentSelectionControlProps/) + }) + test('keeps package export map to documented package surfaces only', () => { expect(Object.keys(packageJson().exports ?? {}).sort()).toEqual([ '.', diff --git a/packages/agent/src/front/chat/PiChatPanel.tsx b/packages/agent/src/front/chat/PiChatPanel.tsx index ecbd102e8..0d41343e7 100644 --- a/packages/agent/src/front/chat/PiChatPanel.tsx +++ b/packages/agent/src/front/chat/PiChatPanel.tsx @@ -52,7 +52,9 @@ import { } from './components/ChatNotices' import { PiConversationSurface } from './components/PiConversationSurface' import { PiChatComposerSurface } from './components/PiChatComposerSurface' +import { AgentSelectionControl } from './components/AgentSelectionControl' import { useExternalRemotePiSession, useRemotePiSessionState } from './piChatPanelHooks' +import { useAddressedAgentSelection } from './useAddressedAgentSelection' import { errorMessage, headersContentKey, @@ -109,6 +111,14 @@ export interface AgentPluginReloadResult { reloaded: boolean } +export interface ControlledAddressedAgentSelection { + agents: import('./useAddressedAgentSelection').AddressedAgentOption[] + selectedAgentTypeId: string | undefined + loading: boolean + error?: Error + onSelect: (agentTypeId: string) => void +} + function normalizeAgentPluginReloadResult(result: AgentPluginReloadResult | string): AgentPluginReloadResult { if (typeof result !== 'string') return result @@ -126,6 +136,10 @@ export interface PiChatPanelProps< sessionId?: string /** Selects the additive addressed AgentGateway transport. Omit for legacy wire. */ agentTypeId?: string + /** Discovers addressed agents and shows a minimal selector. */ + addressedAgentSelection?: boolean + /** Host-owned catalog and selection. When present, this panel never performs Agent discovery. */ + agentSelection?: ControlledAddressedAgentSelection /** Alias kept for consumers that still pass the pre-cutover prop name. */ extraCommands?: SlashCommand[] apiBaseUrl?: string @@ -194,6 +208,8 @@ export function PiChatPanel< >({ sessionId, agentTypeId, + addressedAgentSelection = false, + agentSelection: controlledAgentSelection, extraCommands, apiBaseUrl, workspaceId, @@ -259,6 +275,24 @@ export function PiChatPanel< const sessionListRefreshRef = useRef<(() => void) | undefined>(undefined) const requestHeadersKey = useMemo(() => headersContentKey(requestHeaders), [requestHeaders]) const normalizedRequestHeaders = useMemo(() => normalizedHeadersFromContentKey(requestHeadersKey), [requestHeadersKey]) + const dynamicAgentSelection = addressedAgentSelection && !controlledAgentSelection && !agentTypeId + const agentSelection = useAddressedAgentSelection({ + apiBaseUrl, + requestHeaders: normalizedRequestHeaders, + storageScope, + fetch, + enabled: dynamicAgentSelection, + }) + const visibleAgentSelection = controlledAgentSelection ?? (dynamicAgentSelection ? agentSelection : undefined) + const selectedAgentTypeId = controlledAgentSelection?.selectedAgentTypeId ?? agentTypeId ?? agentSelection.selectedAgentTypeId + const selectAgentTypeId = useCallback((nextAgentTypeId: string) => { + if (controlledAgentSelection) { + controlledAgentSelection.onSelect(nextAgentTypeId) + return + } + agentSelection.selectAgentTypeId(nextAgentTypeId) + }, [agentSelection.selectAgentTypeId, controlledAgentSelection]) + const agentSelectionBlocked = Boolean(visibleAgentSelection && !selectedAgentTypeId) const remoteSessionOptionsWithEvents = useMemo(() => ({ ...remoteSessionOptions, ...(hydrateMessages ? {} : { autoStart: false }), @@ -276,7 +310,7 @@ export function PiChatPanel< }), [hydrateMessages, remoteSessionOptions]) const sessions = usePiSessions({ apiBaseUrl, - agentTypeId, + agentTypeId: selectedAgentTypeId, workspaceId, storageScope, requestHeaders, @@ -284,7 +318,7 @@ export function PiChatPanel< fetch, createRemoteSession, remoteSessionOptions: remoteSessionOptionsWithEvents, - enabled: externalSessionId === undefined, + enabled: externalSessionId === undefined && !agentSelectionBlocked, }) useEffect(() => { if (externalSessionId) { @@ -300,8 +334,8 @@ export function PiChatPanel< } }, [externalSessionId, sessions.refresh]) const externalPiSession = useExternalRemotePiSession({ - sessionId: externalSessionId, - agentTypeId, + sessionId: agentSelectionBlocked ? undefined : externalSessionId, + agentTypeId: selectedAgentTypeId, workspaceId, storageScope, apiBaseUrl, @@ -314,8 +348,8 @@ export function PiChatPanel< const chatState = useRemotePiSessionState(activePiSession) const activeSessionId = externalSessionId ?? sessions.activeSessionId const sessionList = externalSessionId ? [] : sessions.sessions - const sessionsLoading = externalSessionId ? false : sessions.loading - const sessionsError = externalSessionId ? undefined : sessions.error + const sessionsLoading = agentSelectionBlocked || (externalSessionId ? false : sessions.loading) + const sessionsError = agentSelection.error ?? (externalSessionId ? undefined : sessions.error) const selectedChatState = activeSessionId && chatState?.sessionId !== activeSessionId ? undefined : chatState const selectedPiSession = selectedChatState ? activePiSession : undefined const chatStatePending = Boolean(activeSessionId && chatState && chatState.sessionId !== activeSessionId) @@ -866,7 +900,7 @@ export function PiChatPanel< setCommandNotifyState(null) setLocalNotices([]) setDismissedNoticeIds(new Set()) - }, [activeSessionId]) + }, [activeSessionId, selectedAgentTypeId]) useEffect(() => { const currentSessionId = activeSessionId ?? '__none__' @@ -993,7 +1027,7 @@ export function PiChatPanel< window.dispatchEvent(new CustomEvent('boring:chat-session-status', { detail: { sessionId: activeChatSessionId, - ...(agentTypeId ? { agentTypeId } : {}), + ...(selectedAgentTypeId ? { agentTypeId: selectedAgentTypeId } : {}), working: isStreaming, }, })) @@ -1002,7 +1036,7 @@ export function PiChatPanel< // session-list "working" badge disappear while the run is still active. // The selected/running panel emits `working: false` when it observes the // terminal status, and a later remount of an idle session also reconciles it. - }, [activeChatSessionId, agentTypeId, isStreaming]) + }, [activeChatSessionId, isStreaming, selectedAgentTypeId]) const onTextareaKeyDown = useCallback((event: ReactKeyboardEvent) => { if (event.key === 'Escape' && isStreaming) { @@ -1022,6 +1056,7 @@ export function PiChatPanel<
{showSessionSidebar ? ( -