);
@@ -557,7 +569,12 @@ export const FrameNode = memo(
overlayOffsetY={-24}
overlayVisible={labelSemanticallyVisible}
overlayInteractionPriority={isEditingLabel ? 3 : selected ? 2 : 0}
- overlayMaxWidth={Math.max(LABEL_MIN_SCREEN_WIDTH, nodeWidth * zoom)}
+ overlayMaxWidth={Math.max(
+ instructionFrameKind
+ ? INSTRUCTION_LABEL_MIN_SCREEN_WIDTH
+ : LABEL_MIN_SCREEN_WIDTH,
+ nodeWidth * zoom,
+ )}
keepAspectRatio={shouldPreserveFrameAspectRatio({
sizing: data.sizing,
hasMediaChild,
diff --git a/apps/web/src/components/Nodes/frame/InstructionFrameBadge.test.tsx b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.test.tsx
new file mode 100644
index 000000000..8fee91cf3
--- /dev/null
+++ b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.test.tsx
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { InstructionFrameBadge } from './InstructionFrameBadge.tsx';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+describe('InstructionFrameBadge', () => {
+ let container: HTMLDivElement | undefined;
+
+ afterEach(() => {
+ container?.remove();
+ container = undefined;
+ });
+
+ it.each([
+ ['prompt', 'node.promptFrameBadge', 'bg-info'],
+ ['skill', 'node.skillFrameBadge', 'bg-success'],
+ ] as const)('renders the %s semantic pill', (kind, label, tone) => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ act(() => root.render());
+
+ const badge = container.querySelector('span');
+ expect(badge?.textContent).toBe(label);
+ expect(badge?.classList.contains(tone)).toBe(true);
+ expect(badge?.classList.contains('text-fg-inverse')).toBe(true);
+ expect(badge?.classList.contains('rounded-full')).toBe(true);
+ expect(badge?.querySelector('svg')?.getAttribute('aria-hidden')).toBe(
+ 'true',
+ );
+
+ act(() => root.unmount());
+ });
+});
diff --git a/apps/web/src/components/Nodes/frame/InstructionFrameBadge.tsx b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.tsx
new file mode 100644
index 000000000..938e9562d
--- /dev/null
+++ b/apps/web/src/components/Nodes/frame/InstructionFrameBadge.tsx
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import clsx from 'clsx';
+import { BookOpen, MessageSquareQuote } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import type { SpaceInstructionFrameKind } from '@huabu/shared';
+
+export function InstructionFrameBadge({
+ kind,
+}: {
+ kind: SpaceInstructionFrameKind;
+}) {
+ const { t } = useTranslation();
+ const isPrompt = kind === 'prompt';
+
+ return (
+
+ {isPrompt ? (
+
+ ) : (
+
+ )}
+ {t(isPrompt ? 'node.promptFrameBadge' : 'node.skillFrameBadge')}
+
+ );
+}
diff --git a/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx b/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx
index d9c5b8fb1..961a46793 100644
--- a/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/AcpConnectionBadge.tsx
@@ -14,21 +14,14 @@
* States (mutually exclusive; derived upstream from
* {@link useAcpSessionMeta}'s `{loading, error, meta.updatedAt}`):
*
- * • `connecting` — a real `ensureAcpSession` is currently in flight
- * (refresh / set-mode / set-model / set-config-option). Blue
- * breathing dot, no text — the warm-up usually completes within
- * a few hundred ms.
+ * • `connecting` — the GET-only capability cache read is in flight.
*
* • `connected` — default. Cache hit, post-success steady state,
* OR a transient refresh error while we still have a usable
* cached snapshot. Green solid dot, no text — once everything is
* working the badge should be near-invisible chrome.
*
- * • `failed` — the last ensure rejected AND there's no cached
- * snapshot to fall back on. Red dot + uppercase "FAILED" text so
- * the failure is unmissable. Tooltip carries the actual error
- * message when available, falling back to a generic explanation
- * pointing at Settings → External Agents.
+ * • `failed` — the cache read failed and there is no snapshot to show.
*
* The component never renders for internal bindings or before the
* upstream status enum has been derived — the parent gates on
@@ -39,7 +32,6 @@ import { useTranslation } from 'react-i18next';
import { Tooltip } from '@/components/Common/Tooltip';
-import type { AcpEnsureErrorCode } from '@huabu/shared';
import type { FC } from 'react';
export type AcpConnectionStatus = 'connecting' | 'connected' | 'failed';
@@ -49,24 +41,15 @@ interface AcpConnectionBadgeProps {
/** Display name of the bound external agent — shown in tooltips. */
alias: string;
/**
- * Last error from the ensure-session pipeline. Used as the tooltip
- * body for the `failed` state. Ignored for other states.
+ * Last capability-cache read error. Used by the failed-state tooltip.
*/
errorMessage?: string | null;
- /**
- * Categorical error code from the server (when available). Drives
- * a remediation-specific tooltip headline so the user knows the
- * concrete next step (e.g. "Restart worker" vs "Re-create profile")
- * instead of just seeing a raw error message.
- */
- errorCode?: AcpEnsureErrorCode | null;
}
export const AcpConnectionBadge: FC = ({
status,
alias,
errorMessage,
- errorCode,
}) => {
const { t } = useTranslation();
if (status === 'connecting') {
@@ -106,7 +89,7 @@ export const AcpConnectionBadge: FC = ({
// without needing to read the raw error. The detail message is
// appended on a second line so power users can still see the
// underlying server text.
- const headline = headlineForCode(errorCode, alias, t);
+ const headline = t('chat.connectionHeadline.fallback', { alias });
const tooltipText =
errorMessage && errorMessage.length > 0
? `${headline}\n\n${errorMessage}`
@@ -125,65 +108,8 @@ export const AcpConnectionBadge: FC = ({
aria-hidden
className="bg-danger h-1.5 w-1.5 shrink-0 rounded-full"
/>
- {labelForCode(errorCode, t)}
+ {t('chat.connectionLabel.failed')}
);
};
-
-/**
- * Short uppercase label rendered next to the red dot. Kept terse
- * (≤7 chars) so it doesn't blow out the toolbar; the full sentence
- * lives in the tooltip.
- */
-function labelForCode(
- code: AcpEnsureErrorCode | null | undefined,
- t: ReturnType['t'],
-): string {
- switch (code) {
- case 'worker_not_ready':
- case 'placement_unavailable':
- return t('chat.connectionLabel.worker');
- case 'profile_missing':
- return t('chat.connectionLabel.profile');
- case 'spawn_failed':
- case 'session_resume_unavailable':
- return t('chat.connectionLabel.spawn');
- case 'connect_timeout':
- return t('chat.connectionLabel.timeout');
- case 'bridge_not_mounted':
- return t('chat.connectionLabel.starting');
- default:
- return t('chat.connectionLabel.failed');
- }
-}
-
-/**
- * One-sentence remediation headline shown at the top of the tooltip.
- * Each code points at the concrete next step — the raw server
- * message is appended below for diagnostics.
- */
-function headlineForCode(
- code: AcpEnsureErrorCode | null | undefined,
- alias: string,
- t: ReturnType['t'],
-): string {
- switch (code) {
- case 'worker_not_ready':
- case 'placement_unavailable':
- return t('chat.connectionHeadline.workerNotReady');
- case 'profile_missing':
- return t('chat.connectionHeadline.profileMissing', { alias });
- case 'spawn_failed':
- case 'session_resume_unavailable':
- return t('chat.connectionHeadline.spawnFailed', { alias });
- case 'connect_timeout':
- return t('chat.connectionHeadline.connectTimeout', { alias });
- case 'bridge_not_mounted':
- return t('chat.connectionHeadline.bridgeNotMounted');
- case 'internal':
- return t('chat.connectionHeadline.internal', { alias });
- default:
- return t('chat.connectionHeadline.fallback', { alias });
- }
-}
diff --git a/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx b/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx
index 4f51888d4..4f428f9a4 100644
--- a/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/AcpSessionSelectors.test.tsx
@@ -44,17 +44,20 @@ vi.mock('../../Common/Select', () => ({
value,
onChange,
title,
+ placeholder,
}: {
options: Array<{ value: string; label: string }>;
value: string;
onChange: (value: string) => void;
title?: string;
+ placeholder?: string;
}) => (
{pendingFullAccess && (
{
disabled?: boolean;
/** Tooltip / accessible name for the trigger. */
title?: string;
+ /** Placeholder shown when no current value is authoritative. */
+ placeholder?: string;
/** Extra trigger classes, appended to the shared compact class. */
className?: string;
}
@@ -43,6 +45,7 @@ export function SessionSelectorPill({
onChange,
disabled = false,
title,
+ placeholder,
className,
}: SessionSelectorPillProps) {
return (
@@ -52,6 +55,7 @@ export function SessionSelectorPill({
onChange={onChange}
disabled={disabled}
title={title}
+ placeholder={placeholder}
variant="ghost"
shape="pill"
tone="neutral"
diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx
index 4e12b4ba5..04cf716e0 100644
--- a/apps/web/src/components/Panels/ChatPanel/index.tsx
+++ b/apps/web/src/components/Panels/ChatPanel/index.tsx
@@ -240,10 +240,6 @@ export const ChatPanel = ({
refresh: refreshAcpProfiles,
loaded: acpProfilesLoaded,
} = useAcpProfiles();
- const activeExternalProfile =
- agentBinding.kind === 'external'
- ? acpProfiles.find((profile) => profile.id === agentBinding.profileId)
- : undefined;
useEffect(() => {
if (!fixedAgentBinding || bindingsEqual(agentBinding, fixedAgentBinding)) {
@@ -306,8 +302,7 @@ export const ChatPanel = ({
// own binding recipe (see server's session-store `bindingRecipe`),
// so a deleted-profile thread still has a usable transport. If the
// server can't resolve a recipe (orphan v2 record with no profile)
- // the ensure-session call surfaces a clear error and the badge flips
- // to `failed` — that's the right channel for it.
+ // the first explicit interaction surfaces a clear error.
const acpExternalReachable = agentBinding.kind === 'external';
// Slash commands have two independent sources depending on the
@@ -372,17 +367,16 @@ export const ChatPanel = ({
// instead of looking inert.
const {
meta: acpSessionMeta,
+ source: acpSessionMetaSource,
loading: acpSessionMetaLoading,
error: acpSessionMetaError,
- errorCode: acpSessionMetaErrorCode,
+ refresh: refreshAcpSessionMeta,
applyOptimistic: applyAcpSessionMetaOptimistic,
} = useAcpSessionMeta({
threadId,
binding: agentBinding,
canvasId: ownerCanvasId,
enabled: ownerScopeReady && acpExternalReachable,
- autoEnsureOnCacheMiss:
- activeExternalProfile?.launch.kind !== 'agent-team-manifest',
});
// Keep a ref to the latest snapshot so the optimistic handlers can
@@ -413,10 +407,8 @@ export const ChatPanel = ({
// The badge only deviates from `connected` when there is positive
// evidence of trouble:
//
- // connecting: a real `ensureAcpSession` (refresh / set-RPC) is
- // currently in flight
- // failed: the last `ensureAcpSession` rejected AND we have
- // no cached snapshot to fall back on (`updatedAt === 0`)
+ // connecting: the GET-only capability cache read is in flight
+ // failed: the cache read failed and there is no cached snapshot
// connected: everything else — cache hit, post-success steady
// state, or transient ensure failure that still leaves
// us with a valid (if possibly stale) snapshot. We
@@ -449,12 +441,12 @@ export const ChatPanel = ({
// Spawn context threaded into every set-RPC: the selector dropdowns
// are seeded from the no-spawn cached-meta snapshot, so the user can
// switch a value before the session has ever been opened. Passing
- // `{ profileId, canvasId }` lets the server open the session
+ // `{ binding, canvasId }` lets the server realize the complete workload
+ // and open its session
// on-demand instead of rejecting the switch with `session_not_found`.
- const acpSetRpcSpawnCtx = useMemo(
+ const acpControlTarget = useMemo(
() => ({
- profileId:
- agentBinding.kind === 'external' ? agentBinding.profileId : undefined,
+ binding: agentBinding.kind === 'external' ? agentBinding : undefined,
canvasId: ownerCanvasId ?? undefined,
}),
[agentBinding, ownerCanvasId],
@@ -477,7 +469,13 @@ export const ChatPanel = ({
selection: { id: MODE_SELECTION_ID, value: modeId },
});
try {
- await setAcpSessionMode(threadId, { modeId, ...acpSetRpcSpawnCtx });
+ if (!acpControlTarget.binding) return;
+ await setAcpSessionMode(threadId, {
+ modeId,
+ binding: acpControlTarget.binding,
+ canvasId: acpControlTarget.canvasId,
+ });
+ await refreshAcpSessionMeta();
onCommit?.();
} catch (err) {
applyAcpSessionMetaOptimistic({
@@ -491,7 +489,14 @@ export const ChatPanel = ({
);
}
},
- [threadId, applyAcpSessionMetaOptimistic, acpSetRpcSpawnCtx, onCommit, t],
+ [
+ threadId,
+ applyAcpSessionMetaOptimistic,
+ acpControlTarget,
+ refreshAcpSessionMeta,
+ onCommit,
+ t,
+ ],
);
const handleAcpSelectModel = useCallback(
@@ -503,7 +508,13 @@ export const ChatPanel = ({
selection: { id: MODEL_SELECTION_ID, value: modelId },
});
try {
- await setAcpSessionModel(threadId, { modelId, ...acpSetRpcSpawnCtx });
+ if (!acpControlTarget.binding) return;
+ await setAcpSessionModel(threadId, {
+ modelId,
+ binding: acpControlTarget.binding,
+ canvasId: acpControlTarget.canvasId,
+ });
+ await refreshAcpSessionMeta();
onCommit?.();
} catch (err) {
applyAcpSessionMetaOptimistic({
@@ -517,7 +528,14 @@ export const ChatPanel = ({
);
}
},
- [threadId, applyAcpSessionMetaOptimistic, acpSetRpcSpawnCtx, onCommit, t],
+ [
+ threadId,
+ applyAcpSessionMetaOptimistic,
+ acpControlTarget,
+ refreshAcpSessionMeta,
+ onCommit,
+ t,
+ ],
);
const handleAcpSelectConfigOption = useCallback(
@@ -528,11 +546,14 @@ export const ChatPanel = ({
selection: { id: optionId, value },
});
try {
+ if (!acpControlTarget.binding) return;
await setAcpSessionConfigOption(threadId, {
configOptionId: optionId,
value,
- ...acpSetRpcSpawnCtx,
+ binding: acpControlTarget.binding,
+ canvasId: acpControlTarget.canvasId,
});
+ await refreshAcpSessionMeta();
onCommit?.();
} catch (err) {
applyAcpSessionMetaOptimistic({
@@ -546,7 +567,14 @@ export const ChatPanel = ({
);
}
},
- [threadId, applyAcpSessionMetaOptimistic, acpSetRpcSpawnCtx, onCommit, t],
+ [
+ threadId,
+ applyAcpSessionMetaOptimistic,
+ acpControlTarget,
+ refreshAcpSessionMeta,
+ onCommit,
+ t,
+ ],
);
// Question thread replay mode
@@ -807,7 +835,6 @@ export const ChatPanel = ({
status={acpConnectionStatus}
alias={agentBinding.alias}
errorMessage={acpSessionMetaError?.message ?? null}
- errorCode={acpSessionMetaErrorCode}
/>
)}
@@ -906,6 +933,7 @@ export const ChatPanel = ({
agentBinding.kind === 'external' ? (
({
- ensure: vi.fn(),
getCached: vi.fn(),
}));
vi.mock('@/api/acp', () => ({
- ensureAcpSession: apiMocks.ensure,
getAcpThreadCachedMeta: apiMocks.getCached,
}));
@@ -27,13 +25,14 @@ const EMPTY_META = {
availableModels: [],
currentModelId: null,
configOptions: [],
+ selections: {},
sessionInfo: null,
usage: null,
updatedAt: 0,
};
-function Harness({ autoEnsure }: { autoEnsure: boolean }) {
- const { loading, error } = useAcpSessionMeta({
+function Harness() {
+ const { loading, source, meta } = useAcpSessionMeta({
threadId: 'thread-1',
binding: {
kind: 'external',
@@ -41,9 +40,12 @@ function Harness({ autoEnsure }: { autoEnsure: boolean }) {
alias: 'Profile',
},
canvasId: 'canvas-1',
- autoEnsureOnCacheMiss: autoEnsure,
});
- return {loading ? 'loading' : error ? 'error' : 'idle'};
+ return (
+
+ {loading ? 'loading' : `${source}:${meta.configOptions.length}`}
+
+ );
}
let root: Root | undefined;
@@ -54,58 +56,43 @@ afterEach(() => {
container?.remove();
root = undefined;
container = undefined;
- apiMocks.ensure.mockReset();
apiMocks.getCached.mockReset();
});
-async function renderHarness(autoEnsure: boolean): Promise {
+async function renderHarness(): Promise {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
- root?.render();
+ root?.render();
await Promise.resolve();
await Promise.resolve();
});
}
describe('useAcpSessionMeta', () => {
- it('does not send manifest Profiles through command-session auto-ensure', async () => {
+ it('uses a GET-only cold cache result without starting a session', async () => {
apiMocks.getCached.mockResolvedValue({
source: 'none',
+ availableCommands: [],
+ commandsUpdatedAt: 0,
sessionMeta: EMPTY_META,
});
- await renderHarness(false);
-
+ await renderHarness();
expect(apiMocks.getCached).toHaveBeenCalledOnce();
- expect(apiMocks.ensure).not.toHaveBeenCalled();
- expect(container?.textContent).toBe('idle');
- });
-
- it('keeps auto-ensure enabled for command and unknown Profiles', async () => {
- apiMocks.getCached.mockResolvedValue({
- source: 'none',
- sessionMeta: EMPTY_META,
- });
- apiMocks.ensure.mockResolvedValue({
- sessionMeta: {
- ...EMPTY_META,
- availableModes: [{ id: 'default', name: 'Default' }],
- updatedAt: 1,
- },
- });
-
- await renderHarness(true);
-
- expect(apiMocks.ensure).toHaveBeenCalledOnce();
+ expect(apiMocks.getCached).toHaveBeenCalledOnce();
+ expect(container?.textContent).toBe('none:0');
});
- it('ensures a real command session when only profile defaults are cached', async () => {
+ it('renders a Profile capability observation without warming a thread', async () => {
apiMocks.getCached.mockResolvedValue({
source: 'profile',
+ availableCommands: [{ name: 'help', description: 'Help' }],
+ commandsUpdatedAt: 1,
sessionMeta: {
...EMPTY_META,
+ currentModelId: 'model-1',
configOptions: [
{
id: 'allow_all',
@@ -117,23 +104,14 @@ describe('useAcpSessionMeta', () => {
updatedAt: 1,
},
});
- apiMocks.ensure.mockResolvedValue({
- sessionMeta: {
- ...EMPTY_META,
- configOptions: [
- {
- id: 'allow_all',
- name: 'Auto approve',
- type: 'boolean',
- currentValue: false,
- },
- ],
- updatedAt: 2,
- },
- });
- await renderHarness(true);
+ await renderHarness();
- expect(apiMocks.ensure).toHaveBeenCalledOnce();
+ expect(apiMocks.getCached).toHaveBeenCalledWith(
+ 'thread-1',
+ 'canvas-1',
+ 'profile-1',
+ );
+ expect(container?.textContent).toBe('profile:1');
});
});
diff --git a/apps/web/src/hooks/useAcpSessionMeta.ts b/apps/web/src/hooks/useAcpSessionMeta.ts
index df47de003..a7de8d8cd 100644
--- a/apps/web/src/hooks/useAcpSessionMeta.ts
+++ b/apps/web/src/hooks/useAcpSessionMeta.ts
@@ -2,115 +2,27 @@
// Licensed under the MIT license.
/**
- * `useAcpSessionMeta` — fetch and cache the agent-published session
- * metadata (modes / models / config options / info / usage) for the
- * active thread's external binding.
- *
- * ### Lifecycle (post per-profile-cache refactor)
- *
- * - **Mount / thread switch** — fires `GET /cached-meta`, a read-only
- * no-spawn fetch that returns the most recent snapshot the server
- * has cached. Lookup priority on the server:
- * 1. live registry entry (this lifetime),
- * 2. per-thread disk record (`session-store`),
- * 3. **per-profile schema cache** — a warm catalogue shared across
- * threads. Its `current*` values are not accepted as this thread's
- * state; command Profiles ensure a real session before rendering.
- *
- * A hit from (1) or (2) populates immediately with no spawn. A hit from
- * (3) proves only that the profile is known, not which values are active.
- *
- * - **Cache miss → optional one-shot auto-ensure** — command Profiles
- * chain into `refresh()` to fire a real `ensureAcpSession`. Manifest
- * Profiles wait for their first real turn because their session must
- * be opened through the unified Profile driver, not the legacy
- * command-session endpoint.
- *
- * - **Post-ensure schema-empty polling** — `session/new` resolves
- * BEFORE the agent has pushed its mode / model / config-option
- * catalogues (Copilot CLI pushes those 1–3s later). Those pushes
- * land in the server registry / profile cache but DON'T reach the
- * web until an SSE stream opens (i.e. the user sends a message).
- * To avoid the "connected but selectors empty until first message"
- * trap, `refresh()` keeps polling `/cached-meta` (no-spawn) for up
- * to ~60s after a schema-empty ensure resolves, stopping as soon as
- * the agent's push lands. The window has to cover cold-start cases
- * (Copilot CLI launching in a fresh cwd, including auth + workspace
- * indexing, can take 15–30s before the first `config_option_update`
- * ships). `loading` is flipped off the moment ensure resolves so
- * the badge doesn't stay `connecting` during the poll window.
- *
- * - **`refresh` / `refreshIfStale`** — the spawn-triggering path.
- * Calls `ensureAcpSession` (which DOES spawn / resume) and opens a
- * session. Wired to `/` menu open, message-send, and set-RPC
- * handlers in ChatPanel.
- *
- * - **SSE events** — live `session_*_update` frames are merged into
- * the cached snapshot without a round-trip by an internal sink the
- * hook registers by thread via `registerAcpSessionMetaSink`. Mirrored
- * to disk on the server side (both per-thread and per-profile), so the
- * next `/cached-meta` fetch from any client sees the freshest state.
- * Consumers never merge frames themselves.
- *
- * Errors from `refresh` are stored on `error` but never thrown — meta
- * is a polish surface; a failure should degrade to no selectors rather
- * than disrupt chat.
+ * Reads thread/Profile capability observations through the GET-only cache
+ * endpoint and merges live metadata events after a real interaction starts.
+ * Cache misses are normal and never create a workload or ACP process.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
-import { ApiError } from '@/api/_client';
-import { ensureAcpSession, getAcpThreadCachedMeta } from '@/api/acp';
+import { getAcpThreadCachedMeta } from '@/api/acp';
import {
registerAcpSessionMetaSink,
type AcpSessionMetaStreamEvent,
} from '@/hooks/useAgentStream';
import type {
- AcpEnsureErrorCode,
AcpSessionMetaSnapshot,
+ AcpThreadCachedMetaResponse,
AgentBinding,
} from '@huabu/shared';
const STALE_TTL_MS = 10_000;
-/**
- * Offsets (relative to ensure-resolved) for the post-ensure cached-
- * meta polling loop. See `refresh()` for rationale.
- *
- * Total window: ~60s with exponential-ish backoff. Each poll hits
- * `/cached-meta` (no spawn, no auth, no agent round-trip) and commits
- * as soon as the server's cached snapshot becomes schema-non-empty
- * (i.e. the agent's async push has landed in the registry / profile
- * cache).
- *
- * The window MUST cover cold-start cases like Copilot CLI launching
- * in a brand-new cwd (auth handshake + workspace indexing can easily
- * push the first `config_option_update` out to 15–30s). A short
- * window would silently fall back to "only populate on first message
- * send", which is exactly the regression we're solving.
- */
-const POST_ENSURE_POLL_OFFSETS_MS = [
- 400, 1000, 2000, 3000, 5000, 8000, 12000, 15000, 15000,
-] as const;
-
-/**
- * A snapshot is "schema-empty" when none of the three schema-bearing
- * fields have content. This is the canonical "agent hasn't pushed
- * its catalogues yet" state and is what triggers post-ensure
- * polling: `session/new` resolves before the agent's async
- * `config_option_update` / mode catalogues arrive, so the first
- * snapshot is usable for chat but the selector dropdowns would be
- * empty without a follow-up fetch.
- */
-function isSchemaEmpty(snapshot: AcpSessionMetaSnapshot): boolean {
- return (
- snapshot.configOptions.length === 0 &&
- snapshot.availableModes.length === 0 &&
- snapshot.availableModels.length === 0
- );
-}
-
/** Empty snapshot used while no session has been opened. */
const EMPTY_META: AcpSessionMetaSnapshot = {
availableModes: [],
@@ -145,19 +57,12 @@ export interface AcpSessionMetaOptimisticPatch {
export interface UseAcpSessionMetaResult {
/** Snapshot the server most recently confirmed. Never null. */
meta: AcpSessionMetaSnapshot;
+ /** Whether the snapshot belongs to this thread or is a Profile observation. */
+ source: AcpThreadCachedMetaResponse['source'];
/** True while ANY in-flight fetch is pending. */
loading: boolean;
/** Last error from a fetch, or `null`. */
error: Error | null;
- /**
- * Categorical reason for {@link error} when the server returned a
- * recognised `AcpEnsureErrorCode`. `null` when there is no error,
- * the error was a non-API throw (network down), or the server
- * returned an unknown / legacy code. Consumers use this to pick a
- * remediation-specific tooltip / CTA (e.g. "Restart worker" for
- * `worker_not_ready`, "Re-create profile" for `profile_missing`).
- */
- errorCode: AcpEnsureErrorCode | null;
/** Manual re-fetch. */
refresh: () => Promise;
/** TTL-gated re-fetch (see file header). */
@@ -185,12 +90,6 @@ export interface UseAcpSessionMetaOptions {
* for backwards-compat with the original API.
*/
enabled?: boolean;
- /**
- * Whether a total cache miss should open a session through the legacy
- * command-session endpoint. Manifest Profiles use the unified Profile
- * driver and therefore leave this disabled until the first real turn.
- */
- autoEnsureOnCacheMiss?: boolean;
}
/**
@@ -202,9 +101,10 @@ export function useAcpSessionMeta({
binding,
canvasId,
enabled = true,
- autoEnsureOnCacheMiss = true,
}: UseAcpSessionMetaOptions): UseAcpSessionMetaResult {
const [meta, setMeta] = useState(EMPTY_META);
+ const [source, setSource] =
+ useState('none');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -213,6 +113,9 @@ export function useAcpSessionMeta({
const epochRef = useRef(0);
const loadingRef = useRef(false);
const lastFetchedAtRef = useRef(0);
+ const invalidatePending = useCallback(() => {
+ epochRef.current++;
+ }, []);
const bindingKind = binding.kind;
const profileId = binding.kind === 'external' ? binding.profileId : '';
@@ -227,6 +130,7 @@ export function useAcpSessionMeta({
// empty so a freshly-switched internal binding doesn't keep
// showing the previous agent's snapshot.
setMeta(EMPTY_META);
+ setSource('none');
setError(null);
setLoading(false);
loadingRef.current = false;
@@ -236,66 +140,16 @@ export function useAcpSessionMeta({
setLoading(true);
loadingRef.current = true;
try {
- const res = await ensureAcpSession(threadId, {
- canvasId: canvasId ?? undefined,
+ const res = await getAcpThreadCachedMeta(
+ threadId,
+ canvasId ?? undefined,
profileId,
- });
+ );
if (!isCurrent()) return;
setMeta(res.sessionMeta);
+ setSource(res.source);
setError(null);
lastFetchedAtRef.current = Date.now();
- // Flip `loading` off the moment ensure resolves \u2014 the badge
- // would otherwise stay `connecting` for the entire post-ensure
- // poll window (up to 6s) even though the session is already
- // open. The polling below is a background top-up, not part of
- // the user-visible "is the agent reachable" signal.
- setLoading(false);
- loadingRef.current = false;
-
- // Post-ensure cached-meta polling.
- //
- // `session/new` resolves as soon as the agent acknowledges the
- // new session id — BEFORE the agent has pushed its `mode` /
- // `model` catalogues or `config_option_update` snapshot.
- // Copilot CLI in particular pushes those 1–3s later via plain
- // `session/update` notifications that land in the server's
- // registry entry but DO NOT reach the web client until an SSE
- // stream is open (which only happens during a live prompt).
- // Without polling here, the user sees "connected" but empty
- // selectors and has to send a dummy message just to populate
- // the model picker.
- //
- // We only poll when the resolved snapshot is schema-empty (no
- // configOptions, no modes, no models) — cache hits and replayed
- // sessions already have content and skip polling entirely. The
- // loop stops as soon as schema content appears OR the offset
- // list is exhausted, whichever comes first. Each call is a
- // no-spawn `/cached-meta` so it's safe to fire even when the
- // agent is dead (badge stays connected because we already have
- // a non-zero updatedAt from ensure).
- if (isSchemaEmpty(res.sessionMeta)) {
- for (const delay of POST_ENSURE_POLL_OFFSETS_MS) {
- await new Promise((r) => setTimeout(r, delay));
- if (!isCurrent()) return;
- try {
- const poll = await getAcpThreadCachedMeta(
- threadId,
- canvasId ?? undefined,
- profileId || undefined,
- );
- if (!isCurrent()) return;
- if (!isSchemaEmpty(poll.sessionMeta)) {
- setMeta(poll.sessionMeta);
- lastFetchedAtRef.current = Date.now();
- break;
- }
- } catch {
- // Network blip during polling — swallow and keep waiting.
- // The ensure itself already succeeded, so this is purely
- // about catching the late agent push.
- }
- }
- }
} catch (err) {
if (!isCurrent()) return;
setError(err instanceof Error ? err : new Error(String(err)));
@@ -316,6 +170,7 @@ export function useAcpSessionMeta({
);
const applyEvent = useCallback((event: AcpSessionMetaEvent) => {
+ setSource('thread');
setMeta((prev) => {
switch (event.type) {
case 'session_mode_update': {
@@ -409,82 +264,21 @@ export function useAcpSessionMeta({
[],
);
- // Reset meta when binding/thread/canvas changes, then fire a
- // no-spawn cache hydrate so the selector dropdowns can populate
- // immediately from the server's last-known snapshot.
- //
- // Why split this from `refresh`: `refresh` triggers `ensureAcpSession`
- // (which spawns / resumes the agent), whereas this cache hydrate hits
- // the read-only `/cached-meta` endpoint and never spawns — so opening
- // a thread populates the toolbar without paying the cold-start tax.
- //
- // Server-side `/cached-meta` checks three tiers in order:
- // 1. live registry entry (in-memory),
- // 2. per-thread disk record (`session-store`),
- // 3. per-profile schema cache (shared across all threads of the
- // same profile) — passing `profileId` enables this tier.
- //
- // Thread-owned hits are committed directly. Profile-owned hits contain
- // another thread's current values, so they follow the ensure path.
- //
- // **Total miss → optional one-shot auto-ensure**: command Profiles
- // chain into `refresh()`. Manifest Profiles wait for their first real
- // turn because only the unified Profile driver can resolve and launch
- // their prepared deployment.
- //
- // Cache fetch never touches `loading` (which remains semantically
- // "real ensure in flight"), and never touches `error` (cache misses
- // are normal). `refresh` is read through a ref so this effect
- // doesn't re-fire just because the callback identity changed.
- const refreshRef = useRef(refresh);
- useEffect(() => {
- refreshRef.current = refresh;
- }, [refresh]);
-
useEffect(() => {
- const myEpoch = ++epochRef.current;
- const isCurrent = () => epochRef.current === myEpoch;
setMeta(EMPTY_META);
+ setSource('none');
setError(null);
lastFetchedAtRef.current = 0;
-
- if (!threadId || bindingKind !== 'external' || !enabled) return;
-
- void getAcpThreadCachedMeta(
- threadId,
- canvasId ?? undefined,
- profileId || undefined,
- )
- .then((res) => {
- if (!isCurrent()) return;
- if (res.source === 'thread') {
- // Only a live or persisted snapshot belongs to this thread. A
- // profile cache carries another thread's last-known currentValue;
- // presenting it as active can claim auto-approve is enabled while
- // a fresh agent session is still using its safer default.
- setMeta(res.sessionMeta);
- lastFetchedAtRef.current = Date.now();
- return;
- }
- // A profile-only catalogue is useful for schema warm-up but cannot
- // establish active values. Open a real command session just as for a
- // total miss; manifest Profiles wait for their first unified turn.
- if (autoEnsureOnCacheMiss) void refreshRef.current();
- })
- .catch(() => {
- // Cache fetch itself failed (network / 5xx). Treat as cache
- // miss and try to ensure — that's the only path that can
- // actually surface a real failure to the user via the badge.
- if (!isCurrent()) return;
- if (autoEnsureOnCacheMiss) void refreshRef.current();
- });
+ if (threadId && bindingKind === 'external' && enabled) void refresh();
+ return invalidatePending;
}, [
threadId,
bindingKind,
profileId,
canvasId,
enabled,
- autoEnsureOnCacheMiss,
+ refresh,
+ invalidatePending,
]);
useEffect(() => {
@@ -494,43 +288,11 @@ export function useAcpSessionMeta({
return {
meta,
+ source,
loading,
error,
- errorCode: deriveErrorCode(error),
refresh,
refreshIfStale,
applyOptimistic,
};
}
-
-/**
- * Narrow an arbitrary thrown `error` into a categorical
- * {@link AcpEnsureErrorCode} when possible.
- *
- * The server (see `apps/server/src/modules/agent/acp/threads.route.ts`)
- * always sends `code` on the 503 body, and our HTTP client surfaces
- * it on {@link ApiError.code}. Anything that isn't a recognised code
- * (network failure, AbortError, future server-side additions we
- * don't know about yet) yields `null` so the consumer can fall back
- * to a generic message.
- */
-const KNOWN_ENSURE_CODES: readonly AcpEnsureErrorCode[] = [
- 'profile_missing',
- 'bridge_not_mounted',
- 'worker_not_ready',
- 'placement_unavailable',
- 'session_resume_unavailable',
- 'spawn_failed',
- 'connect_timeout',
- 'internal',
-];
-
-function deriveErrorCode(err: Error | null): AcpEnsureErrorCode | null {
- if (!err) return null;
- if (!(err instanceof ApiError)) return null;
- const code = err.code;
- if (!code) return null;
- return (KNOWN_ENSURE_CODES as readonly string[]).includes(code)
- ? (code as AcpEnsureErrorCode)
- : null;
-}
diff --git a/apps/web/src/hooks/useAcpSlashCommands.test.tsx b/apps/web/src/hooks/useAcpSlashCommands.test.tsx
new file mode 100644
index 000000000..2aef1e58e
--- /dev/null
+++ b/apps/web/src/hooks/useAcpSlashCommands.test.tsx
@@ -0,0 +1,127 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useAcpSlashCommands } from './useAcpSlashCommands';
+
+const apiMocks = vi.hoisted(() => ({
+ getCached: vi.fn(),
+}));
+
+vi.mock('@/api/acp', () => ({
+ getAcpThreadCachedMeta: apiMocks.getCached,
+}));
+
+function Harness() {
+ const result = useAcpSlashCommands({
+ threadId: 'thread-1',
+ binding: {
+ kind: 'external',
+ profileId: 'profile-1',
+ alias: 'Profile',
+ },
+ canvasId: 'canvas-1',
+ });
+ return (
+
+ );
+}
+
+let root: Root | undefined;
+let container: HTMLDivElement | undefined;
+
+afterEach(() => {
+ act(() => root?.unmount());
+ container?.remove();
+ root = undefined;
+ container = undefined;
+ apiMocks.getCached.mockReset();
+});
+
+async function renderHarness(): Promise {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ await act(async () => {
+ root?.render();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+describe('useAcpSlashCommands', () => {
+ it('hydrates a cold cache with one GET and no polling', async () => {
+ apiMocks.getCached.mockResolvedValue({
+ source: 'none',
+ availableCommands: [],
+ commandsUpdatedAt: 0,
+ sessionMeta: {
+ availableModes: [],
+ currentModeId: null,
+ availableModels: [],
+ currentModelId: null,
+ configOptions: [],
+ selections: {},
+ sessionInfo: null,
+ usage: null,
+ updatedAt: 0,
+ },
+ });
+
+ await renderHarness();
+
+ expect(apiMocks.getCached).toHaveBeenCalledOnce();
+ expect(container?.textContent).toBe('');
+ });
+
+ it('refreshes the GET cache on later slash-menu intent', async () => {
+ apiMocks.getCached
+ .mockResolvedValueOnce({
+ source: 'profile',
+ availableCommands: [],
+ commandsUpdatedAt: 0,
+ sessionMeta: {
+ availableModes: [],
+ currentModeId: null,
+ availableModels: [],
+ currentModelId: null,
+ configOptions: [],
+ selections: {},
+ sessionInfo: null,
+ usage: null,
+ updatedAt: 0,
+ },
+ })
+ .mockResolvedValueOnce({
+ source: 'profile',
+ availableCommands: [{ name: 'review', description: 'Review' }],
+ commandsUpdatedAt: 2,
+ sessionMeta: {
+ availableModes: [],
+ currentModeId: null,
+ availableModels: [],
+ currentModelId: null,
+ configOptions: [],
+ selections: {},
+ sessionInfo: null,
+ usage: null,
+ updatedAt: 2,
+ },
+ });
+
+ await renderHarness();
+ await act(async () => {
+ container?.querySelector('button')?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(apiMocks.getCached).toHaveBeenCalledTimes(2);
+ expect(apiMocks.getCached).toHaveBeenCalledTimes(2);
+ expect(container?.textContent).toBe('review');
+ });
+});
diff --git a/apps/web/src/hooks/useAcpSlashCommands.ts b/apps/web/src/hooks/useAcpSlashCommands.ts
index 133ac69fb..77b24262e 100644
--- a/apps/web/src/hooks/useAcpSlashCommands.ts
+++ b/apps/web/src/hooks/useAcpSlashCommands.ts
@@ -1,173 +1,35 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
-/**
- * `useAcpSlashCommands` — fetch and cache the agent-defined slash
- * commands for the active thread's external binding.
- *
- * Active only when the thread is bound to an external agent
- * (`binding.kind === 'external'`). For internal bindings it returns
- * an empty list and never hits the server.
- *
- * Session creation is **lazy**: selecting an external agent in the
- * menu does NOT immediately contact the agentlet daemon. The first
- * ACP session is created on-demand when the user opens the slash
- * menu or sends the first message — via {@link refreshIfStale}.
- *
- * Two refresh paths:
- * 1. **On-demand** — {@link UseAcpSlashCommandsResult.refreshIfStale}
- * is invoked by the typeahead host (e.g. ChatInput) on the
- * rising edge of "user wants the slash menu". A TTL gate
- * ({@link STALE_TTL_MS} by default) suppresses redundant
- * network traffic when the user opens / closes the menu in
- * rapid succession. This is the primary entry point.
- * 2. **Manual** — {@link UseAcpSlashCommandsResult.refresh} can be
- * called explicitly when the caller knows the cache is stale.
- *
- * Why no bootstrap effect: agent profiles are templates — no session
- * is created until the user actually interacts. The agentlet daemon
- * auto-suspends idle sessions (via `idleTimeoutSecs`), so avoiding
- * eager session creation reduces unnecessary daemon traffic.
- *
- * Errors are stored on `error` but never thrown — slash-command
- * typeahead is a convenience, not a critical feature, and a failure
- * should silently degrade to no popover rather than disrupt chat.
- */
-
import { useCallback, useEffect, useRef, useState } from 'react';
-import { ensureAcpSession, getAcpThreadCommands } from '@/api/acp';
+import { getAcpThreadCachedMeta } from '@/api/acp';
import type { AgentBinding, AvailableCommand } from '@huabu/shared';
-/**
- * Backoff schedule (ms) for the follow-up re-pulls issued while the
- * command list is still empty after `ensureAcpSession`.
- *
- * The agent pushes `available_commands_update` shortly after
- * `session/new`, but `ensureAcpSession` returns the sessionId before
- * the agentlet relay has even attached (the daemon answers the spawn
- * RPC right after opening the bridge socket). So the commands land in
- * the server-side registry a few hundred ms LATER. On a cold spawn
- * the agent CLI itself can take many seconds to boot before it even
- * emits the list, so we poll persistently with growing gaps until it
- * arrives. The cumulative budget below spans ~30 s, after which we
- * give up and let the short empty-state TTL drive the next attempt on
- * the following menu open.
- */
-const EMPTY_POLL_BACKOFF_MS = [
- 200, 300, 500, 800, 1200, 1500, 2000, 2500, 3000, 3000, 3000, 3000, 3000,
- 3000,
-];
-
-/**
- * localStorage key prefix for the per-profile slash-command cache.
- * The agent's command catalogue is effectively static per profile
- * (Copilot advertises the same ~34 commands for every session), so we
- * persist the last-known list keyed by `profileId` and seed the menu
- * from it OPTIMISTICALLY on the next thread. This collapses the cold
- * spawn wait (agent boot + first `available_commands_update`) from
- * many seconds to instant for any agent the user has used before; the
- * real list silently reconciles once the fresh fetch resolves.
- */
-const CACHE_KEY_PREFIX = 'huabu.acp.slashCommands.';
-
-/** Read the cached command list for a profile, or `[]` on miss/parse error. */
-function readCachedCommands(profileId: string): AvailableCommand[] {
- if (!profileId) return [];
- try {
- const raw = localStorage.getItem(CACHE_KEY_PREFIX + profileId);
- if (!raw) return [];
- const parsed: unknown = JSON.parse(raw);
- return Array.isArray(parsed) ? (parsed as AvailableCommand[]) : [];
- } catch {
- return [];
- }
-}
-
-/** Persist the command list for a profile. Best-effort; swallows quota errors. */
-function writeCachedCommands(
- profileId: string,
- commands: AvailableCommand[],
-): void {
- if (!profileId) return;
- try {
- localStorage.setItem(
- CACHE_KEY_PREFIX + profileId,
- JSON.stringify(commands),
- );
- } catch {
- // localStorage unavailable or over quota — the cache is a pure
- // optimization, so a write failure degrades gracefully to the
- // network path.
- }
-}
-
-/**
- * Default "freshness" window used by {@link UseAcpSlashCommandsResult.refreshIfStale}.
- * A fetch younger than this is treated as still-current and the call
- * becomes a no-op. 10 s strikes a balance: typing `/x` `/y` `/z` in
- * quick succession only triggers one round-trip, while menu opens a
- * few keystrokes apart will pick up a freshly-pushed command set.
- */
const STALE_TTL_MS = 10_000;
-
-/**
- * Much shorter staleness window applied while the command cache is
- * still EMPTY. The agent pushes `available_commands_update` right
- * after `session/new`, but that arrives in the server registry a
- * short moment after `ensureAcpSession` has already returned the
- * sessionId (the bridge relay attaches asynchronously). So once the
- * cache is non-empty {@link STALE_TTL_MS} throttles re-pulls, but
- * while it is empty we re-pull aggressively so the menu recovers the
- * moment the agent's list lands instead of being suppressed by the
- * full 10 s freshness window.
- */
const EMPTY_RETRY_TTL_MS = 1_500;
export interface UseAcpSlashCommandsResult {
- /** Currently-known slash commands. Empty until a fetch resolves with data. */
commands: AvailableCommand[];
- /** True while ANY of the in-flight requests are pending. */
loading: boolean;
- /** Last error from a fetch, or `null`. */
error: Error | null;
- /** Manual re-fetch. Safe to call concurrently — returns the promise. */
refresh: () => Promise;
- /**
- * Re-fetch IFF the last successful fetch is older than `ttlMs`
- * (default {@link STALE_TTL_MS}) AND no fetch is currently in
- * flight. Identity is stable across renders so it can be used as a
- * `useEffect` dependency without triggering spurious calls.
- *
- * Intended for rising-edge triggers (e.g. textarea transitions from
- * "plain text" to "starts with /"). Safe to call frequently — the
- * TTL gate is the throttle.
- */
refreshIfStale: (ttlMs?: number) => void;
}
export interface UseAcpSlashCommandsOptions {
threadId: string | null | undefined;
binding: AgentBinding;
- /** Huabu canvasId; threaded into the ensure-session call. */
canvasId?: string | null;
- /**
- * Master enable switch. When `false`, the hook behaves as if the
- * binding were internal: no fetch, empty `commands`, no error. Use
- * this to gate the request on a precondition the hook can't see
- * itself — e.g. "the bound external agent is currently connected
- * to the bridge" — so we don't fire a guaranteed-to-fail request.
- * Defaults to `true` for backwards-compat with the original API.
- */
enabled?: boolean;
}
/**
- * Subscribe to the slash-command list for a thread bound to an
- * external agent. The hook auto-fetches whenever
- * `{threadId, binding, canvasId}` changes; internal bindings disable
- * the hook entirely.
+ * Read slash commands from the server-owned capability cache.
+ *
+ * Mount and slash-menu refreshes are GET-only. A cache miss is a normal empty
+ * result and never creates a workload or starts an ACP process.
*/
export function useAcpSlashCommands({
threadId,
@@ -175,180 +37,72 @@ export function useAcpSlashCommands({
canvasId,
enabled = true,
}: UseAcpSlashCommandsOptions): UseAcpSlashCommandsResult {
- // Destructure binding into stable scalars so the useCallback dep
- // array is a flat list of primitives. Internal bindings get empty
- // strings — the early-return in `refresh` skips work then.
- const bindingKind = binding.kind;
- const profileId = binding.kind === 'external' ? binding.profileId : '';
-
- // Seed OPTIMISTICALLY from the per-profile cache so a returning
- // agent's menu paints instantly instead of waiting for a cold
- // spawn. Lazy initializer runs once; the binding-change effect
- // below keeps it in sync when the thread/profile switches.
- const [commands, setCommands] = useState(() =>
- enabled && bindingKind === 'external' ? readCachedCommands(profileId) : [],
- );
+ const [commands, setCommands] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
-
- // Monotonic epoch incremented on every fetch and on every effect
- // teardown. Async resumes compare their captured epoch against the
- // current value — if it advanced, this resume is stale and must
- // not write to state. Replaces the prior shared `cancelledRef`
- // which had a TOCTOU race when a new effect setup ran while an
- // older refresh was still suspended (the ref was reset to `false`
- // before the older resume's check).
const epochRef = useRef(0);
-
- // Mirrors of `loading` / last-fetch wall-clock time, accessible
- // synchronously without subscribing renders to either value.
- // `refreshIfStale` reads them inside a `useCallback` that we
- // deliberately want to keep referentially stable across renders
- // (consumers use it as a useEffect dep). State would invalidate
- // the callback on every transition.
const loadingRef = useRef(false);
const lastFetchedAtRef = useRef(0);
+ const invalidatePending = useCallback(() => {
+ epochRef.current++;
+ }, []);
+ const bindingKind = binding.kind;
+ const profileId = binding.kind === 'external' ? binding.profileId : '';
- // Synchronous mirror of `commands.length > 0`, read inside the
- // stable `refreshIfStale` callback so it can pick a shorter
- // staleness window while the cache is empty without subscribing the
- // callback identity to the `commands` value.
- const hasCommandsRef = useRef(false);
-
- // ── Refresh: ensure session → optional delayed re-pull ───────────
const refresh = useCallback(async () => {
const myEpoch = ++epochRef.current;
const isCurrent = () => epochRef.current === myEpoch;
if (!threadId || bindingKind !== 'external' || !enabled) {
- // Reset to empty so a freshly-switched internal binding doesn't
- // keep showing the previous agent's commands. The `!enabled`
- // branch lands here too — caller has told us the precondition
- // for a successful fetch isn't met (e.g. bound agent is
- // disconnected), so behave exactly like an internal binding.
setCommands([]);
setError(null);
setLoading(false);
loadingRef.current = false;
- // Internal bindings have nothing to fetch — leave the staleness
- // clock alone so a subsequent switch back to external still
- // forces a bootstrap on its own.
return;
}
+
setLoading(true);
loadingRef.current = true;
try {
- const res = await ensureAcpSession(threadId, {
- canvasId: canvasId ?? undefined,
+ const response = await getAcpThreadCachedMeta(
+ threadId,
+ canvasId ?? undefined,
profileId,
- });
+ );
if (!isCurrent()) return;
+ setCommands(response.availableCommands);
setError(null);
lastFetchedAtRef.current = Date.now();
-
- // Authoritative result — the agent has actually reported its
- // catalogue (non-empty list, OR an empty list with a real
- // `updatedAt` meaning "this agent genuinely has no commands").
- // Adopt it and refresh the per-profile cache. We deliberately
- // do NOT overwrite an optimistically-seeded list with an empty
- // array while `updatedAt === 0` (commands simply haven't landed
- // yet) — that would blank a returning agent's menu mid-spawn.
- if (res.availableCommands.length > 0 || res.updatedAt > 0) {
- setCommands(res.availableCommands);
- writeCachedCommands(profileId, res.availableCommands);
- }
-
- // If the agent had not pushed yet (commands empty AND
- // updatedAt === 0), poll with growing gaps to catch the push
- // that lands in the registry once the bridge relay attaches and
- // the (possibly cold-booting) agent emits its list. Stop as
- // soon as the list arrives or this resume goes stale. Any
- // optimistic cache stays visible throughout.
- if (res.availableCommands.length === 0 && res.updatedAt === 0) {
- for (const delay of EMPTY_POLL_BACKOFF_MS) {
- await new Promise((r) => setTimeout(r, delay));
- if (!isCurrent()) return;
- const followup = await getAcpThreadCommands(
- threadId,
- canvasId ?? undefined,
- );
- if (!isCurrent()) return;
- if (followup && followup.availableCommands.length > 0) {
- setCommands(followup.availableCommands);
- writeCachedCommands(profileId, followup.availableCommands);
- lastFetchedAtRef.current = Date.now();
- break;
- }
- }
- }
- } catch (err) {
+ } catch (value) {
if (!isCurrent()) return;
- setError(err instanceof Error ? err : new Error(String(err)));
- // Leave `commands` untouched so transient errors don't make
- // the typeahead flicker between populated and empty. Do NOT
- // bump `lastFetchedAtRef` — the next `refreshIfStale` should
- // retry instead of being throttled.
+ setError(value instanceof Error ? value : new Error(String(value)));
} finally {
if (isCurrent()) setLoading(false);
- // Always release the loading gate: a stale resume that skipped
- // the state write above still needs to flip the ref so
- // `refreshIfStale` doesn't deadlock waiting on a phantom load.
- // (Multiple in-flight refreshes can briefly overlap during a
- // binding switch; whichever finishes last clears the flag, and
- // the winner of the epoch race is the one that matters.)
loadingRef.current = false;
}
- }, [threadId, canvasId, bindingKind, profileId, enabled]);
+ }, [threadId, bindingKind, enabled, canvasId, profileId]);
- /**
- * Rising-edge / TTL-gated refresh — see {@link UseAcpSlashCommandsResult.refreshIfStale}.
- * Identity is stable across renders thanks to ref-based gate reads.
- */
const refreshIfStale = useCallback(
(ttlMs: number = STALE_TTL_MS) => {
if (loadingRef.current) return;
- // While the cache is empty, fall back to the aggressive
- // empty-state window so a late `available_commands_update`
- // (pushed shortly after `session/new`) is picked up on the next
- // menu open instead of being throttled by the full freshness
- // TTL.
- const effectiveTtl = hasCommandsRef.current
- ? ttlMs
- : Math.min(ttlMs, EMPTY_RETRY_TTL_MS);
- const last = lastFetchedAtRef.current;
- if (last > 0 && Date.now() - last < effectiveTtl) return;
+ const lastFetchedAt = lastFetchedAtRef.current;
+ const effectiveTtl =
+ commands.length > 0 ? ttlMs : Math.min(ttlMs, EMPTY_RETRY_TTL_MS);
+ if (lastFetchedAt > 0 && Date.now() - lastFetchedAt < effectiveTtl) {
+ return;
+ }
void refresh();
},
- [refresh],
+ [commands.length, refresh],
);
- // Keep the synchronous mirror in lock-step with the rendered list
- // so `refreshIfStale` can branch on "do we have commands yet?"
- // without taking `commands` as a callback dependency.
- useEffect(() => {
- hasCommandsRef.current = commands.length > 0;
- }, [commands]);
-
- // Re-seed the menu when binding/thread/canvas changes so an
- // external→external switch never shows the previous agent's
- // typeahead. We seed from the new profile's cache (not empty) so a
- // returning agent paints instantly; an unknown profile or internal
- // binding seeds empty. Session creation stays LAZY — the actual
- // fetch happens on the first `refreshIfStale` call (slash menu open
- // or first message send), not on mount.
useEffect(() => {
- setCommands(
- enabled && bindingKind === 'external'
- ? readCachedCommands(profileId)
- : [],
- );
+ setCommands([]);
setError(null);
lastFetchedAtRef.current = 0;
- return () => {
- // eslint-disable-next-line react-hooks/exhaustive-deps
- epochRef.current++;
- };
- }, [refresh, enabled, bindingKind, profileId]);
+ if (threadId && bindingKind === 'external' && enabled) void refresh();
+ return invalidatePending;
+ }, [threadId, bindingKind, profileId, enabled, refresh, invalidatePending]);
return { commands, loading, error, refresh, refreshIfStale };
}
diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json
index 5da0d71c2..2bbfc98d4 100644
--- a/apps/web/src/i18n/resources/en/common.json
+++ b/apps/web/src/i18n/resources/en/common.json
@@ -475,6 +475,10 @@
"rowsRange": "Rows (1–{{max}})",
"unframe": "Unframe",
"editFrameName": "Edit frame name",
+ "promptFrameBadge": "Prompt",
+ "promptFrameBadgeDescription": "This Frame provides instructions to Agent Nodes",
+ "skillFrameBadge": "Skill",
+ "skillFrameBadgeDescription": "This Frame extends the authenticated Space skill",
"watchLiveConversation": "Watch live conversation",
"viewConversation": "View conversation",
"openConversation": "Open conversation",
diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json
index ebd51473c..9ca4e2833 100644
--- a/apps/web/src/i18n/resources/zh-CN/common.json
+++ b/apps/web/src/i18n/resources/zh-CN/common.json
@@ -475,6 +475,10 @@
"rowsRange": "行数(1–{{max}})",
"unframe": "取消框架",
"editFrameName": "编辑框架名称",
+ "promptFrameBadge": "提示词",
+ "promptFrameBadgeDescription": "此框架为 Agent 节点提供指令",
+ "skillFrameBadge": "技能",
+ "skillFrameBadgeDescription": "此框架扩展已认证的空间技能",
"watchLiveConversation": "查看实时对话",
"viewConversation": "查看对话",
"openConversation": "打开对话",
diff --git a/docs/README.md b/docs/README.md
index ae0b92f82..fcedc74fc 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -77,27 +77,28 @@ docs/
### Active
-| Doc | Status | Summary |
-| -------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- |
-| [active-space-external-note-watcher.md](./proposals/active-space-external-note-watcher.md) | Proposed | Scope external-note watchers to Spaces with active SSE subscribers. |
-| [agent-node-freshness-cas-plan.md](./proposals/agent-node-freshness-cas-plan.md) | In-Progress | Read/write revision freshness across agent and web paths. |
-| [agent-turn-realtime-sync.md](./proposals/agent-turn-realtime-sync.md) | Proposed | Live attachment and durable event replay for UI, RFS, and Headless turns. |
-| [canvas-checkpoint-plan.md](./proposals/canvas-checkpoint-plan.md) | Proposed | Canvas checkpoint and restoration design. |
-| [canvas-realtime-sync-plan.md](./proposals/canvas-realtime-sync-plan.md) | In-Progress | Roadmap from multi-agent sync to multi-user co-editing. |
-| [content-before-ai-design.md](./proposals/content-before-ai-design.md) | Needs review | Block-level and inline authorship provenance. |
-| [credential-storage-hardening-followups.md](./proposals/credential-storage-hardening-followups.md) | Draft | Follow-up credential storage hardening. |
-| [direct-space-operations.md](./proposals/direct-space-operations.md) | In-Progress | #348 deterministic RFS query and mutation operations for external agents. |
-| [headless-executor-plan.md](./proposals/headless-executor-plan.md) | Partly shipped | Server-side headless canvas executor and structure/content sync. |
-| [interactive-agent-views.md](./proposals/interactive-agent-views.md) | In-Progress | Capability-bound HTML views for persistent external-Agent interaction. |
-| [long-horizon-tasks.md](./proposals/long-horizon-tasks.md) | Partly shipped | Canvas-scoped recursive Agent creation, invocation, and handoff pipeline. |
-| [managed-acp-harness.md](./proposals/managed-acp-harness.md) | Draft | Resource-first Agent Team Profile compilation. |
-| [managed-agent-teams.md](./proposals/managed-agent-teams.md) | In-Progress | Huabu-managed discovery, configuration, preparation, and runtime. |
-| [milkdown-custom-toolbar-plan.md](./proposals/milkdown-custom-toolbar-plan.md) | In-Progress | Huabu-owned Milkdown toolbar and semantic editor commands. |
-| [model-role-routing.md](./proposals/model-role-routing.md) | Proposed | Model selection by runtime role. |
-| [move-selected-nodes-between-spaces.md](./proposals/move-selected-nodes-between-spaces.md) | Proposed | #142 selected-node and Frame-subtree moves between Spaces with bounded compensation. |
-| [multi-backend-storage.md](./proposals/multi-backend-storage.md) | Partly shipped | Phases 1–3: Blob, structured repositories, catalogue, and bounded reads. |
-| [note-auto-height-stable-geometry.md](./proposals/note-auto-height-stable-geometry.md) | Proposed | Revision-aware offscreen Note measurement and stable auto-height geometry. |
-| [space-preview-and-world-redesign.md](./proposals/space-preview-and-world-redesign.md) | In-Progress | View-only Space previews, a preview-based World, and deferred zoom-through navigation. |
+| Doc | Status | Summary |
+| -------------------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- |
+| [active-space-external-note-watcher.md](./proposals/active-space-external-note-watcher.md) | Proposed | Scope external-note watchers to Spaces with active SSE subscribers. |
+| [agent-node-freshness-cas-plan.md](./proposals/agent-node-freshness-cas-plan.md) | In-Progress | Read/write revision freshness across agent and web paths. |
+| [agent-turn-realtime-sync.md](./proposals/agent-turn-realtime-sync.md) | Proposed | Live attachment and durable event replay for UI, RFS, and Headless turns. |
+| [canvas-checkpoint-plan.md](./proposals/canvas-checkpoint-plan.md) | Proposed | Canvas checkpoint and restoration design. |
+| [canvas-realtime-sync-plan.md](./proposals/canvas-realtime-sync-plan.md) | In-Progress | Roadmap from multi-agent sync to multi-user co-editing. |
+| [content-before-ai-design.md](./proposals/content-before-ai-design.md) | Needs review | Block-level and inline authorship provenance. |
+| [credential-storage-hardening-followups.md](./proposals/credential-storage-hardening-followups.md) | Draft | Follow-up credential storage hardening. |
+| [direct-space-operations.md](./proposals/direct-space-operations.md) | In-Progress | #348 deterministic RFS query and mutation operations for external agents. |
+| [external-agent-capability-cache-and-realization.md](./proposals/external-agent-capability-cache-and-realization.md) | Accepted | #160/#162 GET-only capability discovery and canonical first-interaction realization. |
+| [headless-executor-plan.md](./proposals/headless-executor-plan.md) | Partly shipped | Server-side headless canvas executor and structure/content sync. |
+| [interactive-agent-views.md](./proposals/interactive-agent-views.md) | In-Progress | Capability-bound HTML views for persistent external-Agent interaction. |
+| [long-horizon-tasks.md](./proposals/long-horizon-tasks.md) | Partly shipped | Canvas-scoped recursive Agent creation, invocation, and handoff pipeline. |
+| [managed-acp-harness.md](./proposals/managed-acp-harness.md) | Draft | Resource-first Agent Team Profile compilation. |
+| [managed-agent-teams.md](./proposals/managed-agent-teams.md) | In-Progress | Huabu-managed discovery, configuration, preparation, and runtime. |
+| [milkdown-custom-toolbar-plan.md](./proposals/milkdown-custom-toolbar-plan.md) | In-Progress | Huabu-owned Milkdown toolbar and semantic editor commands. |
+| [model-role-routing.md](./proposals/model-role-routing.md) | Proposed | Model selection by runtime role. |
+| [move-selected-nodes-between-spaces.md](./proposals/move-selected-nodes-between-spaces.md) | Proposed | #142 selected-node and Frame-subtree moves between Spaces with bounded compensation. |
+| [multi-backend-storage.md](./proposals/multi-backend-storage.md) | Partly shipped | Phases 1–3: Blob, structured repositories, catalogue, and bounded reads. |
+| [note-auto-height-stable-geometry.md](./proposals/note-auto-height-stable-geometry.md) | Proposed | Revision-aware offscreen Note measurement and stable auto-height geometry. |
+| [space-preview-and-world-redesign.md](./proposals/space-preview-and-world-redesign.md) | In-Progress | View-only Space previews, a preview-based World, and deferred zoom-through navigation. |
### Shipped
diff --git a/docs/architecture/agent-architecture.md b/docs/architecture/agent-architecture.md
index 027e4a0f0..422e85fe9 100644
--- a/docs/architecture/agent-architecture.md
+++ b/docs/architecture/agent-architecture.md
@@ -26,7 +26,7 @@ Key runtime characteristics:
- **Built-in chat is a Deployment**: `POST /api/agent` reuses one live `PiAgentHandle` per `threadId` (get-or-create by Agenetes). On restart, Agenetes supplies durable materialized history through `AgentCreateContext`; that history contains completed Tier-2 turns plus an optional read-time incomplete turn projected from the Tier-1 `turn_start` and event suffix. pi-driver lowers that history through its `materializeHistory` port and seeds the result through pi-agent-core's native `initialState.messages`. Huabu implements the port in [history-replay.ts](../../apps/server/src/modules/agent/agenetes/history-replay.ts) on top of `rebuildTurnMessages`, whose job is to restore the context the live handle would still be holding: each turn replays the canonical `rendered` input array persisted with its submission, so role attribution, `toolCall`/`toolResult` pairing, and images as real vision parts all come back byte-identical to what the model saw. The folded transcript is projected one round at a time, so a multi-round turn replays as `assistant → toolResult → assistant` instead of collapsing into a single block, and a tool call folded with `status: 'failed'` replays as an error result. Only records written before `rendered` existed fall back to re-rendering the stored envelope, and that path drops the neighbourhood, whose point-in-time snapshot would otherwise differ on every rebuild and break the provider's prefix cache. Replay deliberately does not trim: context growth belongs to the conversation, and budgeting only on recovery would make a recovered thread quietly forget what a never-restarted one remembers. Because the payload is not the durable record, the driver reports the materialized `estimatedSize` to `authorizeHistoryLoad`; the mounted `AutoRecoverPolicy` limit is `HISTORY_LOAD_SANITY_LIMIT`, a corruption guard sitting far above any genuine conversation, not a context budget. The route no longer rebuilds transcript context or persists turns. The workload's `initialPreamble` is mapped to pi-agent-core's native `systemPrompt`; later prompt changes use native `set_context`. The pi driver also re-resolves the symbolic `{ type: 'host', id: 'active' }` model ref at every turn boundary.
- **RFS Agent creation and prompting are separate**: `POST /agent` creates a visible Agent Node and may start its first turn, while `POST /agent/:threadId/prompt` addresses an existing conversation. Both Huabu and configured Agent Profiles use the same node-backed invocation service; turns continue draining after the RFS socket disconnects and remain stoppable through the shared explicit stop path.
- **Deployment turns are mutually exclusive**: `AgentThreadService` owns the shared per-`threadId` turn lease, abort controller, process-local active-invocation registry, and durable-turn-start barrier for UI, RFS, and Interactive View invocation. The lease remains held until the run settles, including when a client disconnects. `GET /api/agent/stream/:threadId` validates the active invocation's owner Canvas and independently tails Agenetes Tier 1, so an RFS response and multiple Web tabs can observe one turn without draining each other. History reads include the uncovered Tier-1 suffix and wait for turn start when the matching invocation is active.
-- **UI invocation is service-owned**: `POST /api/agent` delegates dispatch and lease ownership to `AgentThreadService`. When `(canvasId, threadId)` resolves to a fixed Agent Node, the persisted external binding overrides request binding data, the node's launch overrides feed first ACP realization, and the service owns first content plus running/done/error Canvas patches. Selectable Question Nodes and ordinary Canvas Chat retain their existing request binding and Web lifecycle paths.
+- **UI invocation is service-owned**: `POST /api/agent` delegates dispatch and lease ownership to `AgentThreadService`. When `(canvasId, threadId)` resolves to a fixed Agent Node, the persisted external binding overrides request binding data, the node's launch overrides feed first ACP realization, and the service owns first content plus running/done/error Canvas patches. When the pair resolves to any Agent Node, independently of binding policy, the service compiles the Space's recognized Prompt Frames into a bounded user-authored preamble on first realization and persists that snapshot in the workload (`hostContext.spacePrompt` for the built-in driver, a dedicated `initialPreamble` fragment for ACP); later turns reuse the snapshot, including the intentional absence of a prompt on an already-realized legacy thread. Selectable Question Nodes retain their existing request binding and Web lifecycle paths, while ordinary node-less Canvas Chat does not trigger Prompt Frame collection.
- **Abort**: route `signal` → `agent.abort()`; pi-agent-core writes a final message with `stopReason: 'aborted'`. ACP turns check the same signal both before and after session bootstrap, so stopping during process startup never dispatches the pending `session/prompt`. A replacement `/api/agent` request waits (bounded) for any in-flight turn on the same thread to release its lease before acquiring — this absorbs the cancel-then-resend race where the client's fire-and-forget `/stop` has not yet reached the server. A turn that never releases within the timeout, or a genuinely concurrent turn, still receives `409 thread_busy`.
- **Headless source conversations**: the visible Canvas does not determine execution ownership. A World `nodeRef` presentation routes history, reconnect, `/api/agent`, tools, and change records through the source question's Canvas/thread and uses the source question as `anchorNodeId`. Lifecycle patches use the existing server Canvas executor against that owner, so the active World store never authors source status.
@@ -117,7 +117,7 @@ Chat context uses an **envelope-first submission boundary** (see [agent-context.
- [conversation/](../../apps/server/src/modules/agent/conversation) builds each turn's `ChatEnvelope` (user text + selection + anchor + skills). Before calling an agent handle, the selected host adapter renders that envelope into ordered canonical `AgentInput[]` and constructs `{ type: 'huabu.chat', content: envelope, rendered }`.
- `AgentHandle.run(submission, ctx)` receives only data and live turn context; no host render closure crosses into Agenetes. One submission always remains one backend turn. pi preserves multiple canonical members through one atomic `agent.prompt(Message[])`; ACP flattens them in order into one `session/prompt`.
- Agenetes owns conversation persistence per `(namespace, threadId)`: Tier 1 stores the complete submission plus streamed events, and Tier 2 stores the folded completed `AgentTurn`. The historical field remains named `request` for log compatibility but now carries the complete submission. Each driver decides how to lower that record back into its own channel: the built-in agent replays the canonical `rendered` input as native messages, ACP projects it to text, and the driver-level default (used only when a driver exposes no materializer) serializes `rendered` into a JSONL seed. All three fall back to the protocol form for older records written before `rendered` existed.
-- **On disk (Chat-V2 two-tier log).** A canvas's namespace `storage.root` is its `.history/` dir (`canvasAcpNamespace(canvasId)`), so the log lives at `.history/chat_v2/.events.jsonl` — Tier-1 append-only `AgentStreamEvent` delta log ([`FileEventLogStore`](../../external/agenetes/packages/agenetes/src/event-log.ts)) — plus `.history/chat_v2/.turns.jsonl` — Tier-2 folded `AgentTurn`, the **only** tier `history()` reads ([`FileTurnStore`](../../external/agenetes/packages/agenetes/src/turn-store.ts)). Durable workload records sit beside them in `.history/threads.json` ([`FileThreadStore`](../../external/agenetes/packages/agenetes/src/thread-store.ts)). The three file-backed stores plus the two drivers are wired once via `mountAgenetes` in [agenetes/drivers.ts](../../apps/server/src/modules/agent/agenetes/drivers.ts). Legacy chat files are folded into `chat_v2/` at workspace activation ([`migrate-chat-turns.ts`](../../apps/server/src/modules/storage/migrate-chat-turns.ts)).
+- **On disk (Chat-V2 two-tier log).** A canvas's namespace `storage.root` is its `.history/` dir (`canvasAcpNamespace(canvasId)`), so the log lives at `.history/chat_v2/.events.jsonl` — Tier-1 append-only `AgentStreamEvent` delta log ([`FileEventLogStore`](../../external/agenetes/packages/agenetes/src/event-log.ts)) — plus `.history/chat_v2/.turns.jsonl` — Tier-2 folded `AgentTurn`, the **only** tier `history()` reads ([`FileTurnStore`](../../external/agenetes/packages/agenetes/src/turn-store.ts)). These conversation logs deliberately contain the turn submission and assistant/tool transcript only; system prompts, ACP bootstrap text, fixed-node initial instructions, and captured Space Prompt are workload configuration rather than conversation parts and therefore do not appear in either Chat-V2 file. Durable workload records sit beside them in `.history/threads.json` ([`FileThreadStore`](../../external/agenetes/packages/agenetes/src/thread-store.ts)): built-in workloads keep the complete system prompt in `records[threadId].spec.spec.initialPreamble` and the separately recoverable Space Prompt snapshot in `hostContext.spacePrompt`, while ACP workloads keep ordered bootstrap, Space Prompt, and node-specific fragments in `initialPreamble`; ACP delivery state is `state.driverState.initialPreambleDelivered`. At runtime pi maps that preamble to its native system prompt, while ACP prefixes it to the first ordinary `session/prompt` only after the Chat-V2 turn boundary has captured the original submission. Consequently Chat-V2 is not an exact model-input audit log. With `HUABU_DEBUG_PROMPT` enabled, Huabu writes the fully assembled per-turn diagnostic prompt under the Space extension substrate `huabu.prompt.log/.prompt.log`. The three file-backed stores plus the two drivers are wired once via `mountAgenetes` in [agenetes/drivers.ts](../../apps/server/src/modules/agent/agenetes/drivers.ts). Legacy chat files are folded into `chat_v2/` at workspace activation ([`migrate-chat-turns.ts`](../../apps/server/src/modules/storage/migrate-chat-turns.ts)).
- Durable workload records use the strict `agenetes-v2` format. Each record stores `driverSchemaVersion`, the complete opaque `WorkloadSpec`, and `AgentStateSnapshot { driverState, metadata? }`; malformed or unsupported files fail fast. Workspace activation migrates `agenetes-v1` files before any ThreadStore writer opens them, preserving the original as `threads.json.agenetes-v1.bak` and aborting without modification on an invalid record or unknown kind. The selected driver validates its own spec and state. The chat fork endpoint is temporarily unavailable because the existing request does not identify a complete target workload; it returns `501` until #321 defines the target-agent contract.
- Moving an eligible Agent Node between Spaces preserves its `threadId` and uses Agenetes `rehome` to transfer the complete durable workload record, Tier-1 events, and Tier-2 turns between Canvas namespaces without creating a second conversation. Huabu rewrites the workload's target namespace and host-owned Canvas context, including ACP reachback environment, before rehome. A move is rejected while the thread is running or leased, when a Task Run owns its root node or thread, when pending change-review records exist, or when durable conversation state is missing or conflicting. Rehome writes the target logs before making the target record visible, removes the source only after target durability, and restores the source on a determinate failure.
@@ -127,14 +127,15 @@ Chat context uses an **envelope-first submission boundary** (see [agent-context.
[acp/](../../apps/server/src/modules/agent/acp) is the integration layer for external agents. Its trusted built-in catalogue detects and launches GitHub Copilot, Claude Agent, Gemini, Codex, Qwen Code, Kimi Code CLI, OpenCode, Cursor, and Hermes Agent; Manual setup remains available for other ACP-compatible agents and advanced launch commands. Presets with official argument-based full-auto modes expose an auto-approve toggle whose structured recipe controls both the arguments and whether global options precede the ACP subcommand; agents that require environment variables, configuration, or ACP session modes do not expose this launch-command toggle.
-- [service.ts](../../apps/server/src/modules/agent/acp/service.ts) `runAcpAgent()` is the external counterpart of `runAgent`: it performs host rendering, constructs the submission, snapshots a unified Agent Profile for a new thread, and drives one Agenetes turn. A fixed Agent Node may supply bounded launch overrides: `workingDirPath` replaces the effective workload cwd and the cwd embedded in command or manifest recipes, while `additionalInitialPreamble` follows the mandatory Huabu external-agent preamble. Agenetes keeps an already persisted WorkloadSpec authoritative, so later calls cannot mutate a realized thread's launch identity.
+- [external-agent-realization.ts](../../apps/server/src/modules/agent/acp/external-agent-realization.ts) is the sole first-interaction realization boundary for external threads. The first message or mode/model/config control resolves the Agent Node from Canvas state, collects its Space Prompt, applies fixed-node Profile/cwd conflict checks when applicable, calls `buildAcpWorkloadSpec()`, and persists one complete immutable WorkloadSpec through Agenetes. A namespace-and-thread single-flight makes simultaneous first interactions converge on that same spec; later interactions reuse the persisted spec without recollecting instructions.
+- [service.ts](../../apps/server/src/modules/agent/acp/service.ts) owns `buildAcpWorkloadSpec()` and `runAcpAgent()`. The builder snapshots the unified Agent Profile, explicit placement, reachback environment, effective cwd, mandatory Huabu bootstrap, frozen Space Prompt, and node-specific instructions. `runAcpAgent()` receives the already-realized handle and drives only the message turn. Agenetes keeps an already persisted WorkloadSpec authoritative, so later calls cannot mutate launch identity or Space instructions.
- [preprocessor.ts](../../apps/server/src/modules/agent/acp/preprocessor.ts) renders the shared `ChatEnvelope` into canonical `AgentInput[]`. Slash commands become one exclusive `AgentCommandInput`; selection and attachments ride its `context`.
- [`@agenetes/agentlet-host`](../../external/agenetes/packages/agentlet-host) mounts the durably stateless [`@agenetes/agentlet-gateway`](../../external/agenetes/packages/agentlet-gateway), supervises the local agentlet daemon, and injects host-owned authentication. The Gateway owns only live control/session connections, pending RPCs, reconnect buffers, and bounded pre-attach buffering; durable workload and conversation state remains in Agenetes. Ordinary control RPCs time out after 60 seconds, while `server/spawn` has a separate 240-second deadline because it includes ACP `initialize` plus session lifecycle bootstrap, whose two sequential requests may each take up to 90 seconds.
-- [`@agenetes/agent-team`](../../external/agenetes/packages/agent-team) owns the unified Profile registry, Profile schemas, setup state, and manifest-runtime resolution. For current Huabu external chat, `runAcpAgent()` reads the selected Profile and compiles its non-sensitive placement and launch identity in the host composition layer into the canonical opaque ACP spec before calling Agenetes. Command Profiles become concrete command recipes; manifest Profiles become concrete Agent Team recipe references.
-- [`@agenetes/acp-driver`](../../external/agenetes/packages/acp-driver) owns the canonical ACP spec/state schemas, session creation/resume, canonical-input flattening, ACP update translation, and durable state up-reporting. The static DriverMap binds `external` directly to this driver. Before opening a manifest-backed session, a host-injected runtime-environment port resolves current Config values and secrets from the Agent Team registry; these values are merged into the spawn environment but never enter the durable `WorkloadSpec` or `threads.json`. This same port keeps migrated manifest threads recoverable. Live spawn and session caches are isolated by `(agentletId, threadId)`, and unavailable targets fail with `placement_unavailable`. Because ACP has no native system instruction channel, the driver prefixes joined `AgentSpec.initialPreamble` fragments to the first ordinary prompt. Command-only turns do not consume the preamble, and the ACP-owned `initialPreambleDelivered` state is persisted independently from `sessionId`. Session control state is deliberately split in two: the agent-reported surface (`currentModeId` / `currentModelId` / `configOptions[].currentValue`) and `selections`, a map of explicit per-thread user choices keyed by config-option id (`mode`, `model`, and agent-defined ids such as `allow_all`). Only a successful `set_mode` / `set_model` / `set_config_option` writes `selections`; agent pushes never do, because agents such as Copilot CLI implement config options as process-global user settings and broadcast one value to every live session, making the agent-reported value answer "what was picked last, anywhere" rather than "what was picked for this thread". `selections` travels with the rest of `AgentMetadata` and is the authoritative per-thread intent. On resume it is restored unconditionally, because the gated meta hydrate stops applying the moment the agent's bootstrap push lands, and is then replayed onto the agent knob by knob. That replay only skips a knob the agent appears to agree with when a live push from this session established the agent's view: when the view was instead restored from disk it is a copy of the user's own last choice — `recordSessionSelection` mirrors mode/model picks onto `current*`, the agent echoes config-option picks back as `currentValue`, and all of it is persisted — so diffing against it would conclude the agent agrees and skip the one push that mattered, leaving a resumed thread asking for permission its pill says it should not. A rejected knob is forgotten only when the agent definitively refuses it (JSON-RPC `-32601` / `-32602`), so a retired model id cannot wedge the thread while a transport failure cannot destroy durable intent. Session open is lazy, so the replay races the very turn that triggered it: the turn's `session/prompt` and every user set-RPC wait on `awaitSelectionReplay` first, bounded so an unresponsive agent delays a turn rather than hanging it.
-- External-agent idle suspension is host policy: General Settings persists `idleTimeoutSecs` (10 minutes by default, `0` disables suspension), and Huabu injects the current value when a new or resumed ACP process is spawned. Agentlet never suspends a session while a host JSON-RPC request remains in flight; transport teardown closes the ACP client so pending prompts reject and clean up immediately. The long-lived `AcpAgentHandle` self-repairs a suspended lower-level session lazily on the next turn: it snapshots the closed live entry, preserves a recoverable session ID and metadata, and replaces the process/client/session entry only after native resume or load reconnects. This driver-owned repair does not recreate the Handle or refresh its immutable `AgentCreateContext`; closed-session control operations return `session_suspended` until a prompt triggers repair.
+- [`@agenetes/agent-team`](../../external/agenetes/packages/agent-team) owns the unified Profile registry, Profile schemas, setup state, and manifest-runtime resolution. `buildAcpWorkloadSpec()` reads the selected Profile and compiles its non-sensitive placement and launch identity in the host composition layer before Agenetes creation. Command Profiles become concrete command recipes; manifest Profiles become concrete Agent Team recipe references.
+- [`@agenetes/acp-driver`](../../external/agenetes/packages/acp-driver) owns the canonical ACP spec/state schemas, session creation/resume, canonical-input flattening, ACP update translation, and durable state up-reporting. The static DriverMap binds `external` directly to this driver. Before opening a manifest-backed session, a host-injected runtime-environment port resolves current Config values and secrets from the Agent Team registry; these values are merged into the spawn environment but never enter the durable `WorkloadSpec` or `threads.json`. This same port keeps migrated manifest threads recoverable. Live spawn and session caches are isolated by `(agentletId, threadId)`, and unavailable targets fail with `placement_unavailable`. Because ACP has no native system instruction channel, the driver prefixes joined `AgentSpec.initialPreamble` fragments to the first ordinary prompt. A first control causes the host to ensure the session from the canonical spec before calling `handle.control()`; it creates no Chat-V2 turn and does not consume the pending preamble. Session control state is deliberately split in two: the agent-reported surface (`currentModeId` / `currentModelId` / `configOptions[].currentValue`) and `selections`, a map of explicit per-thread user choices keyed by config-option id (`mode`, `model`, and agent-defined ids such as `allow_all`). Only a successful `set_mode` / `set_model` / `set_config_option` writes `selections`; agent pushes never do, because agents such as Copilot CLI implement config options as process-global user settings and broadcast one value to every live session, making the agent-reported value answer "what was picked last, anywhere" rather than "what was picked for this thread". `selections` travels with the rest of `AgentMetadata` and is the authoritative per-thread intent. On resume it is restored unconditionally and replayed onto the agent knob by knob before prompts or user controls proceed. A rejected knob is forgotten only when the agent definitively refuses it, so a retired model id cannot wedge the thread while a transport failure cannot destroy durable intent.
+- External-agent idle suspension is host policy: General Settings persists `idleTimeoutSecs` (10 minutes by default, `0` disables suspension), and Huabu injects the current value when a new or resumed ACP process is spawned. Agentlet never suspends a session while a host JSON-RPC request remains in flight; transport teardown closes the ACP client so pending prompts reject and clean up immediately. The long-lived `AcpAgentHandle` self-repairs a suspended lower-level session lazily on the next turn. Direct driver controls still require a live session, so Huabu's control route first ensures or resumes that session from the canonical persisted spec and then calls `handle.control()`.
- ACP has no native seam for injecting prior assistant messages, so when native resume is unavailable the driver replays history as one prepended text block. It first projects every durable turn through `projectTextHistoryTurn` (`@agenetes/runtime`), which replaces image bodies with a short placeholder — a base64 payload carries no meaning once flattened into text, and inlining it would only inflate the payload. The _projected_ turns are what gets authorized, so the admission estimate prices the block that is actually sent.
-- Before a first turn, Chat hydrates session metadata from cache. The cached-meta response identifies whether its snapshot belongs to the requested thread, is only a profile-level catalogue, or is absent. Live and persisted thread snapshots may populate active selectors without spawning. A profile-level hit is never treated as the new thread's current configuration because values such as Copilot's auto-approve setting can differ when a fresh process starts: command Profiles open a real session to obtain authoritative values, while manifest Profiles wait for the first real turn to compile the Profile and open its ACP session. Huabu separately remembers only successful explicit model and `thought_level` choices in the Profile's host-owned `customData`; a fresh thread snapshots those two preferences, validates them against the new session's published selectors, and replays accepted values before its first prompt. Modes, auto-approve/full-access controls, booleans, and unknown config options remain thread-only. Any persisted thread selection takes precedence over these creation-time Profile preferences.
+- Opening Chat and opening the slash menu read only `GET /api/acp/threads/:threadId/cached-meta`. The response projects cached slash commands and selector catalogues from a live or persisted realized thread first, then from `profile-schema-cache`, and finally returns a successful empty observation. These reads never call `agenetes.create()`, spawn ACP, or create a WorkloadSpec. Profile-level mode/model values may be displayed as last observed; generic config-option values render without a selected value until the current thread reports them or records a successful explicit choice. Live metadata continues updating the current thread and is folded back into the Profile cache. Huabu separately remembers only successful explicit model and `thought_level` choices in the Profile's host-owned `customData`; modes, auto-approve/full-access controls, booleans, and unknown config options remain thread-only.
- Which knob is rendered, and which value it shows, is decided exactly once by `buildAcpSessionSelectors` in [`@huabu/shared`](../../packages/shared/src/utils/acp-session-selectors.ts). It projects a session-meta snapshot into a flat list of selector descriptors, each carrying the channel a change must be routed back through (`mode` / `model` / `config-option`) and whether the shown value came from this thread's `selections` or from the agent's own report. Modern `configOptions` win over the legacy `availableModes` / `availableModels` lists — some agents publish both, and the legacy model list flattens every base model × reasoning effort — but the legacy lists are normalised into the same descriptor shape rather than dropped, because agents that publish no config options at all still depend on them. A recorded selection is ignored when it no longer fits the knob (wrong primitive type, or a value the agent no longer offers) so a retired model id cannot render an empty pill. Chat reads that list and nothing else. Selecting the `agent-full-access` value of a mode selector opens a compact confirmation popover above and left-aligned with ChatInput before Huabu sends the change; cancelling leaves the active mode unchanged without blocking the canvas behind a full-screen modal.
- [profile-store.ts](../../apps/server/src/modules/agent/acp/profile-store.ts) now retains only unmigrated legacy `cliId=agent-team` records long enough to show migration guidance. Ordinary legacy command Profiles preserve their IDs and use the Huabu server working directory when an old record omitted `cwd`. [profiles.route.ts](../../apps/server/src/modules/agent/acp/profiles.route.ts) is the thin loopback-only HTTP adapter over the unified registry, [spawn-orchestrator.ts](../../external/agenetes/packages/acp-driver/src/spawn-orchestrator.ts) targets the selected daemon, and [daemon.route.ts](../../apps/server/src/modules/agent/acp/daemon.route.ts) exposes supervised-daemon status and restart controls.
diff --git a/docs/architecture/agent-context.md b/docs/architecture/agent-context.md
index 904db19d7..5c561a847 100644
--- a/docs/architecture/agent-context.md
+++ b/docs/architecture/agent-context.md
@@ -43,7 +43,7 @@ POST /api/agent (agent.route.ts)
---
-## 3. Prompt-level injection: workspace memory + skills
+## 3. Prompt-level injection: workspace memory + skills + Space Prompt
These two are **cross-turn-stable** system-prompt injections (they don't change per turn), kept separate from the per-turn focus signals in §4.
@@ -62,6 +62,22 @@ Skills are **not tools**; they reach the prompt via two complementary paths:
The catalogue is filtered by the agent's frontmatter `skillScope` (ask/operate/external); a `null` scope injects no catalogue. The difference: catalogue is "a menu you pull from on demand", invoked is "the user named it, full body forced into this turn".
+### 3.3 Space instruction Frames
+
+Space instruction Frames use two channels: Prompt Frames inject instructions into Agent Nodes, while Skill Frames extend the authenticated guide returned by `GET /skill`. Their trimmed, NFC-normalized labels match `prompt`, `prompt: `, `skill`, or `skill: ` case-insensitively and require `labelSource` to be explicitly `user` or `agent`. Auto-generated, missing, or invalid label provenance never activates instruction Frame behavior; a colon without a non-whitespace suffix is also not recognized. The shared `classifySpaceInstructionFrame()` predicate in [node.ts](../../packages/shared/src/types/canvas/node.ts) is the canonical recognizer used by the server and the Frame badges.
+
+[space-instruction-frames.ts](../../apps/server/src/modules/agent/space-instruction-frames.ts) scans the complete Space topology and canonical node records. Instruction Frames are ordered by world `y`, world `x`, then stable Frame id. Only direct children participate; children are ordered by Frame-local `y`, Frame-local `x`, then stable node id, with Text and Note nodes left interleaved in that order.
+
+Prompt Frames inject Text bodies and Note bodies eagerly in the same stable reading order. Each Note is wrapped in a source-attributed `` boundary and its body is capped at 10 KiB UTF-8 before entering the total Prompt budget; per-Note truncation is code-point-safe and reported by node id. Skill Frames keep Notes as lazy self-closing `` catalogue references whose bodies remain available through the RFS/download surface. Empty Text is omitted, unsupported direct-child types and missing canonical records are omitted with diagnostics, and nested descendants do not participate. Locked nodes remain eligible because locking constrains editing and layout rather than visibility; Huabu currently has no node-level private/hidden permission state to bypass.
+
+The complete rendered `` fragment, including stable template prose and diagnostics, is capped at 32 KiB UTF-8. Total-budget truncation is code-point-safe, reports that the budget was exhausted, and lists later nodes omitted completely from the rendered fragment. The live `` module remains capped at 16 KiB. A full-Space collection failure rejects first realization instead of silently producing an incomplete instruction set.
+
+Space Prompt is captured for every Canvas-backed Agent Node, independently of whether its pre-first-turn binding policy is `selectable` or `fixed`. Ordinary node-less Canvas Chat, node-less ACP sessions, and the Memory Agent do not receive it. For an external Agent Node, capture occurs at the first explicit interaction, whether that interaction is a message or a mode/model/config control; GET-only capability reads do not realize the thread. The captured fragment is persisted in the complete durable WorkloadSpec and reused on later turns, so editing Prompt Frames affects newly realized Agent Nodes but does not mutate existing conversations. Built-in agents place it after their trusted AGENT.md/workspace context and before node-specific initial instructions; external ACP agents place it after Huabu's mandatory bootstrap and before node-specific initial instructions. It remains user-authored context subordinate to system, developer, host-policy, and tool instructions, and it coexists with per-turn selection and neighbourhood context rather than replacing them.
+
+Space Prompt and the Huabu Skill intentionally use separate delivery channels while sharing discovery, ordering, and diagnostics but applying channel-specific Note rendering and byte budgets. Prompt Frames state what Agent Nodes in this Space should do, inline their Note bodies, and are captured once. Skill Frames retain lazy Note references, are resolved live on every authenticated `GET /skill`, append after the current root guide, and never enter Agent Node preambles. Anonymous `GET /skill` continues to return only the bundled public guide. A legacy Space-specific `skill.md` remains the root-guide override; when present, live Skill Frames append after that override so existing behavior and new modular customization coexist. Skill Frame collection is additive: if topology or node records cannot be read, the failure is logged and the root guide is still served.
+
+Recognized Prompt and Skill Frames carry a zoom-invariant solid badge beside the editable Frame label. Prompt uses the semantic info tone and Skill uses the semantic success tone; the badge is non-interactive, participates in the existing label width cap and collision visibility, and uses the same shared classifier as delivery.
+
---
## 4. Three "user pointing" signals: selection / anchor / attachment
@@ -162,4 +178,5 @@ Skills are not tools (injection in §3.2). Spatial geometry primitives are in [c
| Tool defs / executor | [tools/definitions.ts](../../apps/server/src/modules/agent/tools/definitions.ts) · [tools/executor.ts](../../apps/server/src/modules/agent/tools/executor.ts) |
| System prompts | [prompt/agents/](../../apps/server/src/prompt/agents) (ask / operate / memory each an AGENT.md, loaded by loader.ts) |
| Skill injection | [skills/catalogue.ts](../../apps/server/src/prompt/skills/catalogue.ts) (catalogue) · [conversation/prompt/invoked-skills.ts](../../apps/server/src/modules/agent/conversation/prompt/invoked-skills.ts) (invoked) |
+| External realization | [external-agent-realization.ts](../../apps/server/src/modules/agent/acp/external-agent-realization.ts) (first interaction) · [space-instruction-frames.ts](../../apps/server/src/modules/agent/space-instruction-frames.ts) (Prompt collection) |
| Spatial / neighbourhood | [canvas-spatial.ts](../../apps/server/src/modules/canvas/canvas-spatial.ts) · [node-neighbourhood.ts](../../apps/server/src/modules/canvas/node-neighbourhood.ts) |
diff --git a/docs/architecture/agent-reachback.md b/docs/architecture/agent-reachback.md
index f64b7c5df..89b084648 100644
--- a/docs/architecture/agent-reachback.md
+++ b/docs/architecture/agent-reachback.md
@@ -8,7 +8,7 @@ The surface separates byte transfer from semantic canvas work:
```text
External agent
- ├─ skill ──────────────> bundled root guide + authenticated advanced guides
+ ├─ skill ──────────────> bundled/override root guide + live Space Skill Frames + advanced guides
├─ download / upload ──> canvas file projection
├─ query ──────────────> canonical SpaceQuery dispatcher
├─ query SNAPSHOT_NODES ──> shared snapshot renderer ──> PNG artifact
@@ -22,23 +22,23 @@ The shipped design record is [`agent-reachback-rfs.md`](../proposals/agent-reach
All endpoints are mounted under `/api/rfs/:canvasId`; `HUABU_RFS_URL` already contains that canvas-scoped base.
-| Endpoint | Responsibility |
-| ---------------------------------- | ------------------------------------------------------------------------------------------- |
-| `GET /skill` | Return the public bundled root guide, or an authenticated canvas-specific root override. |
-| `GET /skill/:skillId` | Return an authenticated advanced guide: `layout`, `tasks`, or `agents`. |
-| `GET /download/` | Stream a known node, artifact, or staged-upload file. |
-| `POST /upload/` | Stage bytes in the canvas `.upload/` directory without creating a node. |
-| `DELETE /upload/` | Remove one exact staged upload. |
-| `POST /agent` | Create a visible Agent Node and optionally start its first turn. |
-| `POST /agent/:threadId/prompt` | Submit a turn to an existing Agent conversation over SSE. |
-| `GET /agent/profiles` | Return available Agent Profile IDs and aliases, including the default `huabu` Profile. |
-| `POST /task/create` | Create a durable Task and its static Task Note. |
-| `POST /task/:taskId/run/create` | Create a Run, its visible root Agent Node, and start the first turn. |
-| `GET /capabilities` | Report the direct-operation protocol, limits, semantics, and supported operation types. |
-| `GET /capabilities/queries/:type` | Return one query's generated JSON Schema, constraints, result description, and examples. |
-| `GET /capabilities/commands/:type` | Return one command's generated JSON Schema, constraints, result description, and examples. |
-| `POST /query` | Validate and execute one bounded `SpaceQuery`, returning a query-discriminated JSON result. |
-| `POST /execute` | Validate and execute an ordered batch of agent-allowed `CanvasCommand` variants. |
+| Endpoint | Responsibility |
+| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
+| `GET /skill` | Return the public bundled root guide; authenticated requests resolve the current root guide and append live Skill Frames. |
+| `GET /skill/:skillId` | Return an authenticated advanced guide: `layout`, `tasks`, or `agents`. |
+| `GET /download/` | Stream a known node, artifact, or staged-upload file. |
+| `POST /upload/` | Stage bytes in the canvas `.upload/` directory without creating a node. |
+| `DELETE /upload/` | Remove one exact staged upload. |
+| `POST /agent` | Create a visible Agent Node and optionally start its first turn. |
+| `POST /agent/:threadId/prompt` | Submit a turn to an existing Agent conversation over SSE. |
+| `GET /agent/profiles` | Return available Agent Profile IDs and aliases, including the default `huabu` Profile. |
+| `POST /task/create` | Create a durable Task and its static Task Note. |
+| `POST /task/:taskId/run/create` | Create a Run, its visible root Agent Node, and start the first turn. |
+| `GET /capabilities` | Report the direct-operation protocol, limits, semantics, and supported operation types. |
+| `GET /capabilities/queries/:type` | Return one query's generated JSON Schema, constraints, result description, and examples. |
+| `GET /capabilities/commands/:type` | Return one command's generated JSON Schema, constraints, result description, and examples. |
+| `POST /query` | Validate and execute one bounded `SpaceQuery`, returning a query-discriminated JSON result. |
+| `POST /execute` | Validate and execute an ordered batch of agent-allowed `CanvasCommand` variants. |
There is no directory-listing endpoint. External agents receive exact node paths in selected-node context or ask the internal agent to discover relevant files.
@@ -68,7 +68,7 @@ The query response returns each artifact's bare `src`, public `downloadPath`, PN
Every operational RFS request and every focused `GET /skill/:skillId` request requires `Authorization: Bearer `. The global server auth hook compares the bearer value with the active Agentlet connection token, and the canvas ID embedded in `HUABU_RFS_URL` scopes route resolution to one Space.
-The only anonymous exception is `GET /skill` with no Authorization header. It returns the bundled root guide without resolving the Canvas, revealing whether it exists, or reading its `skill.md` override. An authenticated root request may resolve that override; a supplied invalid credential returns `401` rather than falling back to public documentation.
+The only anonymous exception is `GET /skill` with no Authorization header. It returns the bundled root guide without resolving the Canvas, revealing whether it exists, reading its `skill.md` override, or collecting Skill Frames. An authenticated root request resolves the override when present and appends live `skill` / `skill: ` Frame modules in deterministic reading order; if the additive Frame collection fails, Huabu logs the failure and still serves the root guide. A supplied invalid credential returns `401` rather than falling back to public documentation.
The shipped token grants access to the complete RFS surface, including direct reads and writes, and `/capabilities` reports both permissions as enabled. The canvas ID scopes route resolution but is not an independent credential or security boundary.
@@ -92,7 +92,7 @@ Parent lineage is best effort. The route resolves `parentThreadId` or `X-Huabu-H
## External-agent bootstrap
-Huabu injects `HUABU_RFS_URL` and `AGENTLET_TOKEN` into the external agent environment. Every external-agent Deployment persists the bootstrap as its initial preamble, including Deployments first created by mode, model, or configuration control requests; startup repair backfills older undelivered records that omitted it. The preamble owns the authentication contract and curl header setup because every external Agent needs them before loading any Skill. The complete basic guide is loaded without credentials from `GET /skill`; advanced layout, Task, recursive-Agent, and Interactive View procedures are loaded on demand from authenticated `GET /skill/layout`, `/skill/tasks`, `/skill/agents`, and `/skill/interactive-views`.
+Huabu injects `HUABU_RFS_URL` and `AGENTLET_TOKEN` into the external agent environment. Every external-agent Deployment persists the bootstrap as its initial preamble, including Deployments first created by mode, model, or configuration control requests; startup repair backfills older undelivered records that omitted it. The preamble owns the authentication contract and curl header setup because every external Agent needs them before loading any Skill. The public basic guide is loaded without credentials from `GET /skill`; repeating that request with authentication adds the current root override and live Space Skill Frames. Advanced layout, Task, recursive-Agent, and Interactive View procedures are loaded on demand from authenticated `GET /skill/layout`, `/skill/tasks`, `/skill/agents`, and `/skill/interactive-views`.
Skills explain when and how to compose workflows, but they do not duplicate the wire protocol. `GET /capabilities` and its per-operation endpoints remain the canonical, schema-derived source for current query and command fields, limits, and semantics.
@@ -118,26 +118,26 @@ An external agent runs as an untrusted third-party CLI. It must receive its Huab
## Code entry points
-| File/dir | Responsibility |
-| ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
-| [`apps/server/src/modules/remote_fs/rfs.route.ts`](../../apps/server/src/modules/remote_fs/rfs.route.ts) | Canvas-scoped file, capability, direct-operation, and ask-agent routes. |
-| [`apps/server/src/modules/remote_fs/space-capabilities.ts`](../../apps/server/src/modules/remote_fs/space-capabilities.ts) | Compact capability handshake and schema-derived per-operation descriptions. |
-| [`apps/server/src/modules/remote_fs/space-execute.ts`](../../apps/server/src/modules/remote_fs/space-execute.ts) | Agent-friendly projection over canonical command preparation and execution. |
-| [`apps/server/src/modules/canvas/space-query.ts`](../../apps/server/src/modules/canvas/space-query.ts) | Canonical query dispatcher over spatial and search services. |
-| [`apps/server/src/modules/canvas/agent-command-preparation.ts`](../../apps/server/src/modules/canvas/agent-command-preparation.ts) | Shared server-owned authorship and built-in read-set annotation. |
-| [`apps/server/src/modules/canvas/snapshot-nodes.ts`](../../apps/server/src/modules/canvas/snapshot-nodes.ts) | Shared node-to-artifact snapshot query implementation. |
-| [`apps/server/src/modules/remote_fs/node-meta.ts`](../../apps/server/src/modules/remote_fs/node-meta.ts) | Safe path projection and node metadata headers. |
-| [`apps/server/src/modules/remote_fs/skill.ts`](../../apps/server/src/modules/remote_fs/skill.ts) | Resolve the public bundled root, authenticated canvas override, and advanced guides. |
-| [`apps/server/src/prompt/external-agent/access-huabu.md`](../../apps/server/src/prompt/external-agent/access-huabu.md) | Agent-facing RFS procedure served by `GET /skill`. |
-| [`apps/server/src/prompt/external-agent/layout.md`](../../apps/server/src/prompt/external-agent/layout.md) | Advanced RFS adapter over the shared Space layout recipes. |
-| [`apps/server/src/prompt/external-agent/tasks.md`](../../apps/server/src/prompt/external-agent/tasks.md) | Durable Task and Run workflow served by `GET /skill/tasks`. |
-| [`apps/server/src/prompt/external-agent/agents.md`](../../apps/server/src/prompt/external-agent/agents.md) | Delegated and recursive Agent workflow served by `GET /skill/agents`. |
-| [`apps/server/src/prompt/external-agent/interactive-views.md`](../../apps/server/src/prompt/external-agent/interactive-views.md) | Interactive View creation and bridge workflow served by `GET /skill/interactive-views`. |
-| [`apps/server/src/modules/interactive-view/`](../../apps/server/src/modules/interactive-view/) | View resource validation, persistence, binding snapshots, and Agent action dispatch. |
-| [`apps/server/src/prompt/external-agent/system-preamble.ts`](../../apps/server/src/prompt/external-agent/system-preamble.ts) | Render the canonical external-agent bootstrap preamble. |
-| [`apps/server/src/modules/agent/acp/reachback-env.ts`](../../apps/server/src/modules/agent/acp/reachback-env.ts) | Inject the canvas-scoped RFS environment into external sessions. |
-| [`apps/server/src/modules/storage/migrate-agenetes-threads.ts`](../../apps/server/src/modules/storage/migrate-agenetes-threads.ts) | Repair persisted undelivered external Deployments that omitted the bootstrap preamble. |
-| [`external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts`](../../external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts) | Fork/supervise the daemon; `filterHostNamespacedEnv` strips the host namespace at the transport boundary. |
-| [`packages/shared/src/types/api/rfs.ts`](../../packages/shared/src/types/api/rfs.ts) | Shared file-plane RFS wire schemas, headers, and constants. |
-| [`packages/shared/src/types/api/space-operations.ts`](../../packages/shared/src/types/api/space-operations.ts) | Canonical direct-operation request, response, capability, and limit contracts. |
-| [`external/agentlet/spec/agent-reachback.md`](../../external/agentlet/spec/agent-reachback.md) | Host-agnostic Agentlet reachback transport contract. |
+| File/dir | Responsibility |
+| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| [`apps/server/src/modules/remote_fs/rfs.route.ts`](../../apps/server/src/modules/remote_fs/rfs.route.ts) | Canvas-scoped file, capability, direct-operation, and ask-agent routes. |
+| [`apps/server/src/modules/remote_fs/space-capabilities.ts`](../../apps/server/src/modules/remote_fs/space-capabilities.ts) | Compact capability handshake and schema-derived per-operation descriptions. |
+| [`apps/server/src/modules/remote_fs/space-execute.ts`](../../apps/server/src/modules/remote_fs/space-execute.ts) | Agent-friendly projection over canonical command preparation and execution. |
+| [`apps/server/src/modules/canvas/space-query.ts`](../../apps/server/src/modules/canvas/space-query.ts) | Canonical query dispatcher over spatial and search services. |
+| [`apps/server/src/modules/canvas/agent-command-preparation.ts`](../../apps/server/src/modules/canvas/agent-command-preparation.ts) | Shared server-owned authorship and built-in read-set annotation. |
+| [`apps/server/src/modules/canvas/snapshot-nodes.ts`](../../apps/server/src/modules/canvas/snapshot-nodes.ts) | Shared node-to-artifact snapshot query implementation. |
+| [`apps/server/src/modules/remote_fs/node-meta.ts`](../../apps/server/src/modules/remote_fs/node-meta.ts) | Safe path projection and node metadata headers. |
+| [`apps/server/src/modules/remote_fs/skill.ts`](../../apps/server/src/modules/remote_fs/skill.ts) | Resolve the public bundled root, authenticated canvas override plus live Skill Frames, and advanced guides. |
+| [`apps/server/src/prompt/external-agent/access-huabu.md`](../../apps/server/src/prompt/external-agent/access-huabu.md) | Agent-facing RFS procedure served by `GET /skill`. |
+| [`apps/server/src/prompt/external-agent/layout.md`](../../apps/server/src/prompt/external-agent/layout.md) | Advanced RFS adapter over the shared Space layout recipes. |
+| [`apps/server/src/prompt/external-agent/tasks.md`](../../apps/server/src/prompt/external-agent/tasks.md) | Durable Task and Run workflow served by `GET /skill/tasks`. |
+| [`apps/server/src/prompt/external-agent/agents.md`](../../apps/server/src/prompt/external-agent/agents.md) | Delegated and recursive Agent workflow served by `GET /skill/agents`. |
+| [`apps/server/src/prompt/external-agent/interactive-views.md`](../../apps/server/src/prompt/external-agent/interactive-views.md) | Interactive View creation and bridge workflow served by `GET /skill/interactive-views`. |
+| [`apps/server/src/modules/interactive-view/`](../../apps/server/src/modules/interactive-view/) | View resource validation, persistence, binding snapshots, and Agent action dispatch. |
+| [`apps/server/src/prompt/external-agent/system-preamble.ts`](../../apps/server/src/prompt/external-agent/system-preamble.ts) | Render the canonical external-agent bootstrap preamble. |
+| [`apps/server/src/modules/agent/acp/reachback-env.ts`](../../apps/server/src/modules/agent/acp/reachback-env.ts) | Inject the canvas-scoped RFS environment into external sessions. |
+| [`apps/server/src/modules/storage/migrate-agenetes-threads.ts`](../../apps/server/src/modules/storage/migrate-agenetes-threads.ts) | Repair persisted undelivered external Deployments that omitted the bootstrap preamble. |
+| [`external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts`](../../external/agenetes/packages/agentlet-host/src/daemon-supervisor.ts) | Fork/supervise the daemon; `filterHostNamespacedEnv` strips the host namespace at the transport boundary. |
+| [`packages/shared/src/types/api/rfs.ts`](../../packages/shared/src/types/api/rfs.ts) | Shared file-plane RFS wire schemas, headers, and constants. |
+| [`packages/shared/src/types/api/space-operations.ts`](../../packages/shared/src/types/api/space-operations.ts) | Canonical direct-operation request, response, capability, and limit contracts. |
+| [`external/agentlet/spec/agent-reachback.md`](../../external/agentlet/spec/agent-reachback.md) | Host-agnostic Agentlet reachback transport contract. |
diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md
index 2b5afc624..e6702e041 100644
--- a/docs/architecture/canvas-storage.md
+++ b/docs/architecture/canvas-storage.md
@@ -24,7 +24,7 @@ Runtime Home-folder activation reserves the namespace switch before preparing an
skills//SKILL.md # user / memory-agent authored skills
/ # dir name = safe(title)
space.json # { canvasId, title, version, state:{nodes,edges,...}, createdAt, updatedAt }
- skill.md # optional per-Space RFS access guide (blob area `guide`)
+ skill.md # optional legacy per-Space RFS root-guide override (blob area `guide`)
nodes/
.md # frontmatter: id/type/label/src/... + content(markdown body)
.artifacts/ # Disk BlobStore mapping for this Space
diff --git a/docs/architecture/canvas-zoom-rendering.md b/docs/architecture/canvas-zoom-rendering.md
index ab86fcd29..e204ae37c 100644
--- a/docs/architecture/canvas-zoom-rendering.md
+++ b/docs/architecture/canvas-zoom-rendering.md
@@ -84,7 +84,7 @@ Because the geometry is exact every frame, the overlay simply re-renders at the
Frame geometry always remains full canvas-space content. A frame does not use `SemanticPlaceholder` because its border and containment boundary are structural information even when zoomed out.
-The editable frame name is different: [`FrameNode`](../../apps/web/src/components/Nodes/frame/FrameNode.tsx) sends it through the screen-space overlay owned by [`NodeWrapper`](../../apps/web/src/components/Nodes/NodeWrapper.tsx), positioned 24 px above the transformed frame top. Its `text-xs` typography therefore remains readable instead of shrinking with the frame.
+The editable frame name is different: [`FrameNode`](../../apps/web/src/components/Nodes/frame/FrameNode.tsx) sends it through the screen-space overlay owned by [`NodeWrapper`](../../apps/web/src/components/Nodes/NodeWrapper.tsx), positioned 24 px above the transformed frame top. Its `text-xs` typography therefore remains readable instead of shrinking with the frame. Frames recognized as Space instruction channels place a non-interactive solid pill in the same overlay: Prompt uses the semantic info tone with a quote icon, while Skill uses the semantic success tone with a book icon. The shared server/Web classifier requires an explicitly user- or agent-authored `prompt` / `prompt: ...` / `skill` / `skill: ...` label, so the visual signal cannot drift from delivery behavior.
Fixed screen-space labels can overlap when nested frame top edges converge during zoom-out. `FrameNode` compares the vertical screen-space gap to the nearest frame ancestor and hides the nested label below 22 px, with a 4 px hysteresis buffer around subsequent hide/reveal transitions.
@@ -102,7 +102,7 @@ When frame labels collide, the higher-priority label wins:
The first three interaction states force the affected label to remain visible and use matching overlay layers in descending order. With no interaction, the outer frame wins because zoomed-out views prioritize structural context over nested detail; the inner label returns after sufficient screen-space separation.
-Frame label width is capped to the transformed frame width with a 48 px usability floor. An overflowing name truncates with an ellipsis so the clipped remainder is visible as such; the ellipsis disappears while the label is being edited, where the input scrolls instead.
+Frame label width is capped to the transformed frame width with a 48 px usability floor, raised to 112 px when an instruction badge is present so the badge cannot collapse the editable name hit target. The badge remains visible while the remaining name width shrinks; an overflowing name truncates with an ellipsis so the clipped remainder is visible as such. The ellipsis disappears while the label is being edited, where the input scrolls instead.
`FrameNode` owns the hierarchy and collision policy because it is frame-specific. `NodeWrapper` remains generic: it converts node coordinates to screen coordinates, applies owner-provided semantic visibility and width, handles interaction reveal, and performs opacity/FLIP transitions.
diff --git a/docs/proposals/external-agent-capability-cache-and-realization.md b/docs/proposals/external-agent-capability-cache-and-realization.md
new file mode 100644
index 000000000..93aed74fa
--- /dev/null
+++ b/docs/proposals/external-agent-capability-cache-and-realization.md
@@ -0,0 +1,300 @@
+# External Agent Capability Cache and Canonical Realization
+
+Status: Shipped
+Last updated: 2026-09-05
+
+## Context
+
+Issue [#160](https://github.com/microsoft/Huabu/issues/160) adds Space Prompt Frames whose content is frozen into an Agent Node's durable WorkloadSpec when that Agent is first realized.
+
+Issue [#162](https://github.com/microsoft/Huabu/issues/162) removes UI-triggered ACP warm sessions and replaces them with GET-only Profile/Harness capability discovery.
+
+These changes share one lifecycle problem. Huabu currently starts an ACP session before the first user message so slash commands and selector metadata are available. A pre-message mode, model, or config-option change then obtains an Agenetes handle by calling `agenetes.create()` with a transport-minimal WorkloadSpec. Because Agenetes correctly treats a persisted Deployment spec as immutable and authoritative, that minimal spec can prevent the later message path from persisting the canonical fixed-node binding, launch overrides, node-specific instructions, and Space Prompt.
+
+The root problem is not Prompt Frame discovery and not ACP session creation itself. The system currently lets metadata discovery, live-session creation, mutable control state, and immutable workload realization overlap without one explicit ownership boundary.
+
+## Decision
+
+Huabu will separate capability discovery, workload realization, and live ACP session state.
+
+1. The Web reads last-observed external-agent capabilities from a GET-only Profile/Harness cache and never starts an ACP session merely to populate slash commands or selectors.
+2. The first explicit Agent interaction realizes one complete canonical WorkloadSpec. An explicit interaction is either user input or a mode, model, or config-option control.
+3. Control and message routes call the same server-owned realization pipeline and differ only after they receive the realized handle: control calls `handle.control()`, while a message calls `handle.run()`.
+4. Fixed Agent Node identity is resolved from canonical Canvas state on the server. Client-supplied Profile or working-directory fields cannot override a fixed binding or its launch overrides.
+5. ACP initial preamble remains pending after control-only realization and is delivered once by the driver on the first ordinary prompt.
+6. WorkloadSpec is complete and immutable from its first durable write; mutable mode, model, config, session, and metadata state remains in the driver snapshot.
+7. No compatibility migration or repair is required for bootstrap-only records created on the unshipped development branch.
+
+## Goals
+
+- Make opening an Agent Node, reading metadata, and opening slash-command UI side-effect free with respect to ACP process creation and durable workload creation.
+- Preserve immediate UI rendering from cached commands and selector catalogues when prior observations exist.
+- Allow a cold cache to degrade to empty/default UI without blocking the first user message.
+- Ensure a first control and a first user message produce the same canonical WorkloadSpec.
+- Snapshot the Space Prompt at the first explicit interaction for Agent Nodes.
+- Keep the existing Agenetes persisted-spec-authoritative invariant.
+- Reconcile cached UI state with live agent reports after a real session starts.
+- Keep permission-expanding selections safe when only stale or cross-thread observations exist.
+
+## Non-goals
+
+- Migrating or repairing bootstrap-only records created before this proposal ships.
+- Guaranteeing that every harness publishes metadata before the first assistant response completes.
+- Treating cached Profile/Harness observations as authoritative thread selections.
+- Standardizing every harness on identical commands, models, modes, or config options.
+- Changing Chat-V2 so control operations or initial preambles become conversation turns.
+- Starting background capability-discovery processes solely to keep caches fresh.
+
+## Terminology and ownership
+
+| Concept | Owner | Lifetime | Durable |
+| ------------------------------ | ---------------------------------------- | ------------------------------ | ---------------------------- |
+| Profile | Huabu Profile registry | User-managed | Yes |
+| Harness capability observation | Profile/Harness capability cache | Until refreshed or invalidated | Yes |
+| Canonical WorkloadSpec | Agenetes ThreadStore | Thread lifetime | Yes |
+| Driver selection and metadata | AgentStateSnapshot | Thread lifetime | Yes |
+| ACP process/session | ACP driver session registry and Agentlet | Live runtime | Native session identity only |
+| Chat conversation | Agenetes Tier-1/Tier-2 stores | Thread lifetime | Yes |
+
+A Profile is a launch configuration associated with a harness. Commands and selector catalogues are usually harness capabilities, but their exact values may also depend on harness version, account entitlement, placement, launch configuration, or workspace. The first implementation keeps observations Profile-associated because that is the narrowest existing identity that safely contains those differences. A later optimization may deduplicate catalogue data by a stronger harness capability key.
+
+## Capability cache
+
+### Cached data
+
+The cache stores the latest observed capability catalogue for a Profile:
+
+```typescript
+interface ExternalAgentCapabilityObservation {
+ profileId: string;
+ commands: AvailableCommand[];
+ availableModes: SessionMode[];
+ availableModels: SessionModel[];
+ configOptions: SessionConfigOption[];
+ lastObservedValues: {
+ modeId?: string;
+ modelId?: string;
+ options?: Record;
+ };
+ observedAt: number;
+}
+```
+
+The concrete contract should reuse existing shared ACP metadata types rather than introduce parallel command or selector shapes.
+
+`lastObservedValues` describes what a prior real session reported. It is not a confirmed value for a new thread and does not become durable thread intent merely because the UI displayed it.
+
+### UI read path
+
+Opening an Agent Node performs a GET-only cache read:
+
+```text
+Agent Node opens
+ -> resolve Profile identity
+ -> GET cached Profile capabilities
+ -> render catalogue and last-observed values
+ -> do not spawn an agent
+ -> do not create a WorkloadSpec
+```
+
+A cache miss returns a successful empty observation or an explicit `source: none` result. The UI may hide unavailable selectors, show neutral defaults, and leave slash commands empty. Chat remains usable.
+
+The Web no longer calls an ensure-session endpoint on cache miss, slash-menu open, metadata refresh, or ordinary panel mount.
+
+### Cache refresh
+
+When a real ACP session publishes commands, modes, models, or config options, Huabu updates both the active thread UI and the Profile capability cache.
+
+The cache is observational and last-write-wins. A removed model, mode, command, or option disappears when a newer complete catalogue supersedes it. Partial agent updates retain the existing merge semantics required by the ACP protocol.
+
+Permission-expanding values such as full access or auto-approve must not be presented as active for a new thread solely because they were last observed on another thread. They become active UI state only after the current session reports them or the current thread records a successful explicit selection.
+
+## Canonical workload realization
+
+### Trigger
+
+The first explicit Agent interaction triggers realization:
+
+- sending user input
+- setting mode
+- setting model
+- setting a config option
+
+The following do not trigger realization:
+
+- opening an Agent Node
+- reading capability cache
+- opening slash-command UI
+- reading commands or metadata
+- restoring cached UI state
+
+### Realization output
+
+Realization creates one complete immutable WorkloadSpec containing:
+
+- thread identity, namespace, driver kind, and workload type
+- authoritative Agent binding
+- Profile recipe and explicit Agentlet placement
+- reachback environment
+- effective working directory
+- mandatory Huabu bootstrap
+- frozen Space Prompt for an Agent Node
+- node-specific additional initial instructions
+- complete launch overrides
+- no bootstrap-only or preparatory record
+
+The initial implementation does not add a driver-schema field solely as a realization marker. Canonicality is structural because every Huabu ACP `agenetes.create()` call for user threads goes through the one realization service and this change intentionally carries no bootstrap-only compatibility records. If a future design introduces preparatory records, it must add an explicit versioned marker before those records coexist; it must not infer lifecycle from logs or preamble contents.
+
+### Fixed and non-fixed threads
+
+For a fixed Agent Node, the server resolves the node by `(canvasId, threadId)` and treats its binding and launch overrides as authoritative. Client-supplied Profile and working-directory values are consistency hints only. A mismatch returns an explicit conflict response and does not create a workload.
+
+For a non-fixed external Agent Node, the server uses the schema-validated requested binding and supported request configuration while still collecting and injecting the Space Prompt. A node-less external thread does not receive a Space Prompt.
+
+### Shared entry point
+
+Control and message routes use one realization service:
+
+```typescript
+const realized = await realizeExternalAgentThread({
+ canvasId,
+ threadId,
+ requestedBinding,
+});
+
+if (interaction.type === 'control') {
+ return realized.handle.control(interaction.control);
+}
+
+return realized.handle.run(interaction.submission, interaction.context);
+```
+
+The service reads an existing canonical record when present. Otherwise it resolves the target, collects the Prompt when eligible, builds the complete spec through the canonical ACP builder, persists it through Agenetes, and returns the realized handle.
+
+No control route may construct a reduced WorkloadSpec.
+
+## Realization and session boundaries
+
+The workload and ACP session have independent lifecycles:
+
+```text
+Workload: unrealized -- first control/message --> realized and immutable
+Session: absent ----- first control/message --> live <--> suspended/resumed
+```
+
+Removing UI-triggered warm sessions means a normal new thread has no ACP process before its first explicit interaction. The realization pipeline creates the canonical workload before the driver creates or resumes the real session.
+
+A control-only first interaction realizes the workload and creates the session but does not write a Chat-V2 turn and does not consume `initialPreamble`. The ACP driver delivers the ordered preamble once when the first ordinary prompt is lowered to `session/prompt`.
+
+Session suspension, resume, process restart, and native session recovery reuse the immutable WorkloadSpec and never recollect the Space Prompt.
+
+## Concurrency and failure
+
+Realization uses a short-lived gate keyed by Canvas namespace and thread ID.
+
+```text
+first control ----\
+ -> realization gate -> one canonical WorkloadSpec
+first message ----/
+```
+
+The gate covers target resolution, Prompt collection, spec construction, and durable creation. It is released before a long-running control or message operation.
+
+The existing turn lease continues to protect message execution and is not replaced by the realization gate.
+
+If realization fails before durable creation, no partial spec is written and a later explicit interaction may retry. If realization succeeds but a subsequent control is rejected, the finalized workload remains valid; workload identity and a mutable control result are separate concerns.
+
+## Selection semantics
+
+The UI resolves displayed values in this order:
+
+```text
+current thread's confirmed explicit selection
+ > current live session report
+ > Profile cache last-observed value
+ > neutral harness/default presentation
+```
+
+Selecting a value before the first message is an explicit interaction. Huabu realizes the workload, creates the real session, validates the requested value against the live capability surface, sends the control, and records it only on success.
+
+Agents such as Copilot CLI may persist model selection themselves. Huabu does not assume a particular harness persistence scope. It displays the cached observation initially, then reconciles to what the new real session reports.
+
+## HTTP surface
+
+The final surface separates safe reads from side-effecting interactions:
+
+| Operation | Method semantics | Side effects |
+| ----------------------------------- | ---------------- | --------------------------------------- |
+| Read Profile capability observation | GET | None |
+| Read realized thread metadata | GET | None |
+| Send mode/model/config control | POST | May realize workload and create session |
+| Send user input | POST/SSE | May realize workload and create session |
+
+The UI-triggered ensure-session endpoint is removed from normal product flow and may be deleted when no internal caller requires it. A GET endpoint must never spawn or resume an Agent.
+
+Every new or changed HTTP contract is defined once in `packages/shared/src/types/api`, validated with `safeParse` on the server, and imported type-only by the Web.
+
+## Implementation sequence
+
+Issue #162 is the implementation prerequisite for the #160 correction, but both ship in PR [#161](https://github.com/microsoft/Huabu/pull/161).
+
+### Phase 1: Cache-only capability UI
+
+- Expose one GET-only Profile capability observation contract.
+- Reuse the existing Profile schema cache as the initial storage boundary.
+- Make slash commands and session selectors read cached Profile observations without calling `ensureAcpSession`.
+- Preserve cold-cache empty/default UI and live event reconciliation.
+- Remove normal Web dependencies on `POST /threads/:threadId/session`.
+
+### Phase 2: Canonical realization service
+
+- Extract the shared external-thread realization pipeline.
+- Resolve fixed targets from server-side Canvas state.
+- Collect Space Prompt and apply launch overrides exactly once.
+- Keep canonicality structural for this first shipped version; require an explicit marker before any future preparatory record is introduced.
+- Add a namespace-and-thread realization gate.
+
+### Phase 3: Unify interactions
+
+- Route first and subsequent user messages through the realization service.
+- Route mode, model, and config-option controls through the same service.
+- Remove reduced WorkloadSpec construction from ACP control routes.
+- Keep control operations outside Chat-V2 and preserve one-shot initial preamble delivery.
+
+### Phase 4: Remove obsolete warm-session flow
+
+- Delete unused Web API wrappers, polling, connection states, and ensure-session route code.
+- Keep ACP driver session creation as an internal consequence of real control or run operations.
+- Update architecture documentation to describe the shipped cache and realization boundaries.
+
+## Regression coverage
+
+- Opening an Agent Node, reading capability cache, and opening slash-command UI do not start an ACP session or create a ThreadRecord.
+- A cold cache does not block the first user message.
+- Cached commands and selectors render before a session exists.
+- Live metadata reconciles the UI and refreshes the Profile cache.
+- A first fixed-node control persists the same canonical spec as a first fixed-node message.
+- Space Prompt and node-specific instructions are present after first-control realization.
+- A first control does not write a Chat-V2 turn or consume the initial preamble.
+- The first ordinary prompt delivers the preamble once.
+- Subsequent controls mutate driver state without changing the WorkloadSpec.
+- Concurrent first control and message requests create one canonical workload.
+- Fixed binding mismatch is rejected without persisting a workload.
+- Selectable external Agent Nodes realize with a Space Prompt, while node-less external threads do not.
+- Permission-expanding cached observations are not presented as confirmed current-thread state.
+
+## Code entry points
+
+| File/dir | Responsibility |
+| ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
+| [`apps/server/src/modules/agent/acp/external-agent-realization.ts`](../../apps/server/src/modules/agent/acp/external-agent-realization.ts) | Shared first-message/first-control realization, conflict validation, session ensure, and namespace/thread single-flight. |
+| [`apps/server/src/modules/agent/agent-thread.service.ts`](../../apps/server/src/modules/agent/agent-thread.service.ts) | Message lifecycle and dispatch through the shared external realization boundary. |
+| [`apps/server/src/modules/agent/agent-thread-resolver.ts`](../../apps/server/src/modules/agent/agent-thread-resolver.ts) | Canonical Agent Node lookup plus fixed binding and launch-override validation. |
+| [`apps/server/src/modules/agent/acp/service.ts`](../../apps/server/src/modules/agent/acp/service.ts) | Canonical ACP WorkloadSpec builder and message execution. |
+| [`apps/server/src/modules/agent/acp/threads.route.ts`](../../apps/server/src/modules/agent/acp/threads.route.ts) | Current session metadata and control routes; reduced spec creation and warm-session endpoints are removed from normal flow. |
+| [`apps/server/src/modules/agent/acp/profile-schema-cache.ts`](../../apps/server/src/modules/agent/acp/profile-schema-cache.ts) | Existing Profile-associated capability observation cache. |
+| [`apps/server/src/modules/agent/space-instruction-frames.ts`](../../apps/server/src/modules/agent/space-instruction-frames.ts) | Deterministic Space Prompt collection used during Agent Node realization. |
+| [`apps/web/src/hooks/useAcpSessionMeta.ts`](../../apps/web/src/hooks/useAcpSessionMeta.ts) | Selector catalogue cache read and live-session reconciliation. |
+| [`apps/web/src/hooks/useAcpSlashCommands.ts`](../../apps/web/src/hooks/useAcpSlashCommands.ts) | Slash-command cache read without session creation. |
+| [`external/agenetes/packages/acp-driver/src/handle.ts`](../../external/agenetes/packages/acp-driver/src/handle.ts) | ACP session creation, control/run behavior, and one-shot preamble delivery. |
+| [`external/agenetes/packages/agenetes/src/instance.ts`](../../external/agenetes/packages/agenetes/src/instance.ts) | Immutable persisted-spec and live-handle lifecycle. |
diff --git a/packages/shared/src/types/__tests__/agenetes-protocol.conformance.test.ts b/packages/shared/src/types/__tests__/agenetes-protocol.conformance.test.ts
index 0b6aa7565..5fbb12491 100644
--- a/packages/shared/src/types/__tests__/agenetes-protocol.conformance.test.ts
+++ b/packages/shared/src/types/__tests__/agenetes-protocol.conformance.test.ts
@@ -216,12 +216,25 @@ const toAnswerPermission = (req: AcpPermissionDecisionRequest): ControlMsg => ({
describe('ControlMsg conformance', () => {
it('maps every current control-route body onto a valid ControlMsg', () => {
+ const binding = {
+ kind: 'external' as const,
+ alias: 'Profile',
+ profileId: 'p1',
+ };
const msgs: ControlMsg[] = [
toCancel(),
- toSetMode({ modeId: 'agent', profileId: 'p1', canvasId: 'c1' }),
- toSetModel({ modelId: 'gpt-5', cwd: '/repo' }),
- toSetConfigOption({ configOptionId: 'auto-approve', value: true }),
- toSetConfigOption({ configOptionId: 'thought-level', value: 'high' }),
+ toSetMode({ modeId: 'agent', binding, canvasId: 'c1' }),
+ toSetModel({ modelId: 'gpt-5', binding, cwd: '/repo' }),
+ toSetConfigOption({
+ configOptionId: 'auto-approve',
+ value: true,
+ binding,
+ }),
+ toSetConfigOption({
+ configOptionId: 'thought-level',
+ value: 'high',
+ binding,
+ }),
toAnswerPermission({ requestId: 'r1', optionId: 'allow' }),
toAnswerPermission({ requestId: 'r2', cancelled: true }),
];
diff --git a/packages/shared/src/types/api/acp-tool.ts b/packages/shared/src/types/api/acp-tool.ts
index 25d8d5a35..607568937 100644
--- a/packages/shared/src/types/api/acp-tool.ts
+++ b/packages/shared/src/types/api/acp-tool.ts
@@ -29,8 +29,8 @@ export {
// Session-meta variants — surfaced for explicit shape narrowing in
// `handleSessionMetaUpdate` and for validating the
// `config_options_update` / `current_mode_update` / `session_info_update`
- // / `usage_update` SSE payloads that travel through
- // `EnsureAcpSessionResponse` and `AcpThreadCommandsResponse`.
+ // / `usage_update` SSE payloads that travel through cached capability
+ // responses and live events.
zSessionConfigOption as ZAcpSessionConfigOption,
zSessionMode as ZAcpSessionMode,
zSessionModeState as ZAcpSessionModeState,
diff --git a/packages/shared/src/types/api/acp.ts b/packages/shared/src/types/api/acp.ts
index f1e2f9d75..33291254f 100644
--- a/packages/shared/src/types/api/acp.ts
+++ b/packages/shared/src/types/api/acp.ts
@@ -269,125 +269,20 @@ export interface AvailableCommand {
input?: { hint: string } | null;
}
-/**
- * Request body for `POST /api/acp/threads/:threadId/session` — eagerly
- * open (or reuse) the per-thread ACP session so the web client can pull
- * slash commands BEFORE the user submits their first prompt.
- *
- * The server resolves `profileId` to a live agentlet agent (spawning
- * one on the daemon if needed) before opening the session.
- */
-export interface EnsureAcpSessionRequest {
- /** Huabu canvasId scoping the session sandbox. Optional only for the no-canvas edge case. */
- canvasId?: string;
- /** The user-configured profile this thread is bound to. */
- profileId: string;
- /**
- * Optional `cwd` override for `session/new`. When omitted the server
- * uses the profile's `cwd`. (Reserved for future per-thread cwd
- * pinning; current UI does not expose it.)
- */
- cwd?: string;
-}
-
-/** Response body for `POST /api/acp/threads/:threadId/session`. */
-export interface EnsureAcpSessionResponse {
- /** ACP session id (opaque to the client). */
- sessionId: string;
- /**
- * Currently-cached slash commands for this session. May be empty
- * when the agent has not pushed its list yet — callers should
- * follow up with `GET /api/acp/threads/:threadId/commands` after a
- * short delay to catch a late push.
- */
- availableCommands: AvailableCommand[];
- /** Epoch ms when `availableCommands` was last refreshed. 0 if never. */
- updatedAt: number;
- /**
- * Snapshot of session-meta (modes / models / config options / info /
- * usage) the server has cached. Always present (defaults to empty
- * fields when the agent has not pushed anything). Web UI uses this
- * to seed selector dropdowns before any SSE frame arrives.
- */
- sessionMeta: AcpSessionMetaSnapshot;
-}
-
-/** Query for `GET /api/acp/threads/:threadId/commands`. */
-export interface AcpThreadCommandsQuery {
- /** Canvas containing the persisted workload placement. */
- canvasId?: string;
-}
-
-/** Response body for `GET /api/acp/threads/:threadId/commands`. */
-export interface AcpThreadCommandsResponse {
- sessionId: string;
- availableCommands: AvailableCommand[];
- /** Epoch ms when `availableCommands` was last refreshed. 0 if never. */
- updatedAt: number;
- /**
- * Snapshot of session-meta (modes / models / config options / info /
- * usage). Same shape as on {@link EnsureAcpSessionResponse}.
- */
- sessionMeta: AcpSessionMetaSnapshot;
-}
-
/**
* Response body for `GET /api/acp/threads/:threadId/cached-meta`.
*
* Read-only, **never spawns** an agent. Returns whatever snapshot the
- * server has on disk (from a prior live session) plus, if a live
- * session is still in the in-process registry, the freshest in-memory
- * state on top.
+ * server has on disk, the Profile capability observation, or the freshest
+ * live state. It also carries the cached slash-command catalogue.
*
* Cache miss (no persisted record and no live entry) returns an empty
* snapshot with `updatedAt === 0`. The UI uses this to seed the
- * selector dropdowns and badge "optimistic green" state before any
- * real ensure-session call is made — i.e. opening a thread no longer
- * needs to spawn an agentlet just to populate the toolbar.
+ * selector dropdowns and slash menu without spawning an agentlet.
*/
-export interface AcpThreadCachedMetaResponse {
- /**
- * Ownership of the returned snapshot. Profile snapshots provide only a
- * warm catalogue; their current values belong to another thread and must
- * not be presented as this thread's active configuration.
- */
- source: 'thread' | 'profile' | 'none';
- sessionMeta: AcpSessionMetaSnapshot;
-}
-
-/**
- * Categorical error codes returned in `ApiErrorBody.code` from
- * `POST /api/acp/threads/:threadId/session` on 503.
- *
- * Mirrors the server's `AcpEnsureErrorCode` (in
- * `apps/server/src/modules/agent/acp/errors.ts`). The web client
- * switches on this to render a remediation-specific badge tooltip
- * and CTA (e.g. "Restart worker", "Re-create profile").
- *
- * Wire-stable: renaming or removing a code is a breaking change for
- * any out-of-tree client. Adding a new code is safe (clients fall
- * back to the generic message).
- *
- * • `profile_missing` — bound profile no longer exists.
- * • `bridge_not_mounted` — embedded agentlet bridge still booting.
- * • `worker_not_ready` — agentlet daemon worker never came online.
- * • `placement_unavailable` — the explicitly targeted agentlet is offline.
- * • `session_resume_unavailable` — persisted native session is gone.
- * • `spawn_failed` — daemon rejected the spawn RPC (bad recipe).
- * • `connect_timeout` — agent process started but never opened WS
- * (most often: interactive auth needed, e.g. expired Copilot
- * OAuth, or immediate crash).
- * • `internal` — uncategorised throw; treat as a bug.
- */
-export type AcpEnsureErrorCode =
- | 'profile_missing'
- | 'bridge_not_mounted'
- | 'worker_not_ready'
- | 'placement_unavailable'
- | 'session_resume_unavailable'
- | 'spawn_failed'
- | 'connect_timeout'
- | 'internal';
+export type AcpThreadCachedMetaResponse = z.infer<
+ typeof acpThreadCachedMetaResponseSchema
+>;
// ─── Session-meta snapshot & set-RPCs ──────────────────────────────────
//
@@ -450,69 +345,6 @@ export interface AcpSessionMetaSnapshot {
* Request body for `POST /api/acp/threads/:threadId/mode`.
* Switches the session's currently-active mode.
*/
-export interface SetAcpSessionModeRequest {
- modeId: string;
- /**
- * Optional spawn context. The selector dropdowns are populated from
- * a no-spawn cached-meta snapshot, so the user can switch mode
- * BEFORE any live session exists. When set, the server opens (or
- * reuses) the session on-demand before applying the RPC instead of
- * failing with `session_not_found`. Omit only when the caller knows
- * a live session already exists.
- */
- profileId?: string;
- canvasId?: string;
- cwd?: string;
-}
-
-/** Response body for `POST /api/acp/threads/:threadId/mode`. */
-export interface SetAcpSessionModeResponse {
- ok: true;
- /** Echo back the freshly-set mode id; agent confirms via SSE separately. */
- modeId: string;
-}
-
-/**
- * Request body for `POST /api/acp/threads/:threadId/model`.
- * Switches the session's currently-active model.
- */
-export interface SetAcpSessionModelRequest {
- modelId: string;
- /** Optional spawn context — see {@link SetAcpSessionModeRequest}. */
- profileId?: string;
- canvasId?: string;
- cwd?: string;
-}
-
-/** Response body for `POST /api/acp/threads/:threadId/model`. */
-export interface SetAcpSessionModelResponse {
- ok: true;
- modelId: string;
-}
-
-/**
- * Request body for `POST /api/acp/threads/:threadId/config-option`.
- *
- * `value` follows the ACP `SessionConfigValueId` shape:
- * • `string` for `select` options (the chosen `id`)
- * • `boolean` for `boolean` options
- */
-export interface SetAcpSessionConfigOptionRequest {
- configOptionId: string;
- value: string | boolean;
- /** Optional spawn context — see {@link SetAcpSessionModeRequest}. */
- profileId?: string;
- canvasId?: string;
- cwd?: string;
-}
-
-/** Response body for `POST /api/acp/threads/:threadId/config-option`. */
-export interface SetAcpSessionConfigOptionResponse {
- ok: true;
- configOptionId: string;
- value: string | boolean;
-}
-
// ─── Permission decisions ──────────────────────────────────────────────
//
// Reply channel for a `permission_request` SSE event (see
@@ -600,39 +432,21 @@ export const acpSessionMetaSnapshotSchema = z.object({
updatedAt: z.number().int().nonnegative(),
}) satisfies z.ZodType;
-/** Schema mirror of {@link EnsureAcpSessionRequest}. */
-export const ensureAcpSessionRequestSchema = z.object({
+export const acpThreadCachedMetaQuerySchema = z.object({
canvasId: z.string().min(1).optional(),
- profileId: z.string().min(1),
- cwd: z.string().min(1).optional(),
-}) satisfies z.ZodType;
-
-/** Schema mirror of {@link EnsureAcpSessionResponse}. */
-export const ensureAcpSessionResponseSchema = z.object({
- sessionId: z.string().min(1),
- availableCommands: z.array(availableCommandSchema),
- updatedAt: z.number().int().nonnegative(),
- sessionMeta: acpSessionMetaSnapshotSchema,
-}) satisfies z.ZodType;
-
-/** Schema mirror of {@link AcpThreadCommandsQuery}. */
-export const acpThreadCommandsQuerySchema = z.object({
- canvasId: z.string().min(1).optional(),
-}) satisfies z.ZodType;
-
-/** Schema mirror of {@link AcpThreadCommandsResponse}. */
-export const acpThreadCommandsResponseSchema = z.object({
- sessionId: z.string().min(1),
- availableCommands: z.array(availableCommandSchema),
- updatedAt: z.number().int().nonnegative(),
- sessionMeta: acpSessionMetaSnapshotSchema,
-}) satisfies z.ZodType;
+ profileId: z.string().min(1).optional(),
+});
+export type AcpThreadCachedMetaQuery = z.infer<
+ typeof acpThreadCachedMetaQuerySchema
+>;
/** Schema mirror of {@link AcpThreadCachedMetaResponse}. */
export const acpThreadCachedMetaResponseSchema = z.object({
source: z.enum(['thread', 'profile', 'none']),
+ availableCommands: z.array(availableCommandSchema),
+ commandsUpdatedAt: z.number().int().nonnegative(),
sessionMeta: acpSessionMetaSnapshotSchema,
-}) satisfies z.ZodType;
+});
/** Schema mirror of {@link AcpPermissionDecisionRequest}. */
export const acpPermissionDecisionSchema = z.object({
@@ -648,49 +462,65 @@ export const acpPermissionDecisionResponseSchema = z.object({
// ─── Session-meta set-RPCs (zod) ───────────────────────────────────────
-/** Schema mirror of {@link SetAcpSessionModeRequest}. */
-export const setAcpSessionModeRequestSchema = z.object({
- modeId: z.string().min(1),
- profileId: z.string().min(1).optional(),
+const externalAgentInteractionTargetSchema = z.object({
+ binding: z.object({
+ kind: z.literal('external'),
+ alias: z.string().min(1),
+ profileId: z.string().min(1),
+ }),
canvasId: z.string().min(1).optional(),
cwd: z.string().min(1).optional(),
-}) satisfies z.ZodType;
+});
+
+export const setAcpSessionModeRequestSchema =
+ externalAgentInteractionTargetSchema.extend({
+ modeId: z.string().min(1),
+ });
+export type SetAcpSessionModeRequest = z.infer<
+ typeof setAcpSessionModeRequestSchema
+>;
-/** Schema mirror of {@link SetAcpSessionModeResponse}. */
export const setAcpSessionModeResponseSchema = z.object({
ok: z.literal(true),
modeId: z.string().min(1),
-}) satisfies z.ZodType;
+});
+export type SetAcpSessionModeResponse = z.infer<
+ typeof setAcpSessionModeResponseSchema
+>;
-/** Schema mirror of {@link SetAcpSessionModelRequest}. */
-export const setAcpSessionModelRequestSchema = z.object({
- modelId: z.string().min(1),
- profileId: z.string().min(1).optional(),
- canvasId: z.string().min(1).optional(),
- cwd: z.string().min(1).optional(),
-}) satisfies z.ZodType;
+export const setAcpSessionModelRequestSchema =
+ externalAgentInteractionTargetSchema.extend({
+ modelId: z.string().min(1),
+ });
+export type SetAcpSessionModelRequest = z.infer<
+ typeof setAcpSessionModelRequestSchema
+>;
-/** Schema mirror of {@link SetAcpSessionModelResponse}. */
export const setAcpSessionModelResponseSchema = z.object({
ok: z.literal(true),
modelId: z.string().min(1),
-}) satisfies z.ZodType;
+});
+export type SetAcpSessionModelResponse = z.infer<
+ typeof setAcpSessionModelResponseSchema
+>;
-/** Schema mirror of {@link SetAcpSessionConfigOptionRequest}. */
export const setAcpSessionConfigOptionRequestSchema = z.object({
+ ...externalAgentInteractionTargetSchema.shape,
configOptionId: z.string().min(1),
value: z.union([z.string(), z.boolean()]),
- profileId: z.string().min(1).optional(),
- canvasId: z.string().min(1).optional(),
- cwd: z.string().min(1).optional(),
-}) satisfies z.ZodType;
+});
+export type SetAcpSessionConfigOptionRequest = z.infer<
+ typeof setAcpSessionConfigOptionRequestSchema
+>;
-/** Schema mirror of {@link SetAcpSessionConfigOptionResponse}. */
export const setAcpSessionConfigOptionResponseSchema = z.object({
ok: z.literal(true),
configOptionId: z.string().min(1),
value: z.union([z.string(), z.boolean()]),
-}) satisfies z.ZodType;
+});
+export type SetAcpSessionConfigOptionResponse = z.infer<
+ typeof setAcpSessionConfigOptionResponseSchema
+>;
// ─── Agent-profile / daemon schemas ────────────────────────────────────
diff --git a/packages/shared/src/types/canvas/index.ts b/packages/shared/src/types/canvas/index.ts
index 257b3c05a..505bebf5e 100644
--- a/packages/shared/src/types/canvas/index.ts
+++ b/packages/shared/src/types/canvas/index.ts
@@ -71,6 +71,7 @@ export type {
QuestionNodeData,
QuestionNodeStatus,
LabelSource,
+ SpaceInstructionFrameKind,
NodeData,
} from './node.js';
@@ -100,6 +101,12 @@ export {
isQuestionNode,
getQuestionNodeStatus,
normalizeOrigin,
+ classifySpaceInstructionFrameLabel,
+ classifySpaceInstructionFrame,
+ isPromptFrameLabel,
+ isPromptFrame,
+ isSkillFrameLabel,
+ isSkillFrame,
} from './node.js';
// Edge types
diff --git a/packages/shared/src/types/canvas/instruction-frame.test.ts b/packages/shared/src/types/canvas/instruction-frame.test.ts
new file mode 100644
index 000000000..54f43839e
--- /dev/null
+++ b/packages/shared/src/types/canvas/instruction-frame.test.ts
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ classifySpaceInstructionFrame,
+ classifySpaceInstructionFrameLabel,
+} from './node.js';
+
+describe('Space instruction Frame labels', () => {
+ it.each([
+ ['prompt', 'prompt'],
+ [' Prompt: Review ', 'prompt'],
+ ['SKILL', 'skill'],
+ ['skill: Research', 'skill'],
+ ] as const)('classifies %s as %s', (label, expected) => {
+ expect(classifySpaceInstructionFrameLabel(label)).toBe(expected);
+ });
+
+ it.each(['prompt:', 'skill: ', 'prompt module', '', null])(
+ 'rejects invalid label %s',
+ (label) => {
+ expect(classifySpaceInstructionFrameLabel(label)).toBeNull();
+ },
+ );
+
+ it('requires explicit user or agent label provenance', () => {
+ expect(classifySpaceInstructionFrame('skill', 'user')).toBe('skill');
+ expect(classifySpaceInstructionFrame('prompt: Task', 'agent')).toBe(
+ 'prompt',
+ );
+ expect(classifySpaceInstructionFrame('skill', 'auto')).toBeNull();
+ expect(classifySpaceInstructionFrame('prompt', undefined)).toBeNull();
+ });
+});
diff --git a/packages/shared/src/types/canvas/node.ts b/packages/shared/src/types/canvas/node.ts
index 0816b96aa..2989149fe 100644
--- a/packages/shared/src/types/canvas/node.ts
+++ b/packages/shared/src/types/canvas/node.ts
@@ -104,6 +104,50 @@ export function normalizeOrigin(raw: unknown): NodeOrigin | undefined {
/** Who set the node label — controls whether auto-title may overwrite it */
export type LabelSource = 'auto' | 'user' | 'agent';
+export type SpaceInstructionFrameKind = 'prompt' | 'skill';
+
+/**
+ * Classify a label that opts a Frame into a Space-level instruction channel.
+ *
+ * Instruction Frames are intentionally label-based so users and agents can
+ * create them through existing canvas operations.
+ */
+export function classifySpaceInstructionFrameLabel(
+ label: unknown,
+): SpaceInstructionFrameKind | null {
+ if (typeof label !== 'string') return null;
+ const match = /^(prompt|skill)(?:\s*:\s*\S[\s\S]*)?$/i.exec(
+ label.trim().normalize('NFC'),
+ );
+ const kind = match?.[1]?.toLowerCase();
+ return kind === 'prompt' || kind === 'skill' ? kind : null;
+}
+
+/** Only explicitly authored labels may activate instruction Frame semantics. */
+export function classifySpaceInstructionFrame(
+ label: unknown,
+ labelSource: unknown,
+): SpaceInstructionFrameKind | null {
+ if (labelSource !== 'user' && labelSource !== 'agent') return null;
+ return classifySpaceInstructionFrameLabel(label);
+}
+
+export function isPromptFrameLabel(label: unknown): boolean {
+ return classifySpaceInstructionFrameLabel(label) === 'prompt';
+}
+
+export function isPromptFrame(label: unknown, labelSource: unknown): boolean {
+ return classifySpaceInstructionFrame(label, labelSource) === 'prompt';
+}
+
+export function isSkillFrameLabel(label: unknown): boolean {
+ return classifySpaceInstructionFrameLabel(label) === 'skill';
+}
+
+export function isSkillFrame(label: unknown, labelSource: unknown): boolean {
+ return classifySpaceInstructionFrame(label, labelSource) === 'skill';
+}
+
/** Font family logical names. CSS font stacks are resolved on the UI side. */
export const NODE_FONT_FAMILIES = ['default', 'serif', 'mono', 'hand'] as const;
export type NodeFontFamily = (typeof NODE_FONT_FAMILIES)[number];