From 2dccaf663fcee793bc0d1a9c9e2e043f3abb2e0a Mon Sep 17 00:00:00 2001
From: oxoxDev <164490987+oxoxDev@users.noreply.github.com>
Date: Wed, 8 Jul 2026 12:09:27 +0530
Subject: [PATCH] feat(orchestration): receive typed v2 harness-session stream
(#4652)
---
.../intelligence/AttentionQueueItem.test.tsx | 28 +
.../OrchestrationChatPrimitives.test.tsx | 39 ++
.../OrchestrationChatPrimitives.tsx | 90 ++-
.../OrchestrationSidebar.test.tsx | 3 +
.../intelligence/OrchestrationSidebar.tsx | 14 +-
.../intelligence/SelfIdentityCard.test.tsx | 60 ++
.../intelligence/SelfIdentityCard.tsx | 62 +-
.../TinyPlaceOrchestrationTab.test.tsx | 1 +
.../TinyPlaceOrchestrationTab.tsx | 29 +
app/src/lib/i18n/ar.ts | 6 +-
app/src/lib/i18n/bn.ts | 6 +-
app/src/lib/i18n/de.ts | 7 +-
app/src/lib/i18n/en.ts | 6 +-
app/src/lib/i18n/es.ts | 6 +-
app/src/lib/i18n/fr.ts | 6 +-
app/src/lib/i18n/hi.ts | 6 +-
app/src/lib/i18n/id.ts | 6 +-
app/src/lib/i18n/it.ts | 6 +-
app/src/lib/i18n/ko.ts | 6 +-
app/src/lib/i18n/pl.ts | 6 +-
app/src/lib/i18n/pt.ts | 6 +-
app/src/lib/i18n/ru.ts | 6 +-
app/src/lib/i18n/zh-CN.ts | 6 +-
.../lib/orchestration/orchestrationClient.ts | 33 +
.../orchestration/useOrchestrationChats.ts | 10 +
src/openhuman/orchestration/attention.rs | 222 +++++-
src/openhuman/orchestration/ingest.rs | 652 ++++++++++++++++--
src/openhuman/orchestration/ops.rs | 35 +
src/openhuman/orchestration/schemas.rs | 247 ++++++-
src/openhuman/orchestration/store.rs | 335 +++++++--
src/openhuman/orchestration/tools.rs | 5 +
src/openhuman/orchestration/types.rs | 454 +++++++++++-
src/openhuman/tinyplace/manifest.rs | 20 +-
src/openhuman/tinyplace/mod.rs | 1 +
vendor/tinyplace | 2 +-
35 files changed, 2271 insertions(+), 156 deletions(-)
diff --git a/app/src/components/intelligence/AttentionQueueItem.test.tsx b/app/src/components/intelligence/AttentionQueueItem.test.tsx
index 17f9c9552..c2791cbce 100644
--- a/app/src/components/intelligence/AttentionQueueItem.test.tsx
+++ b/app/src/components/intelligence/AttentionQueueItem.test.tsx
@@ -16,6 +16,19 @@ const approval: AttentionItem = {
createdAt: '2026-07-06T10:00:00Z',
};
+// A REMOTE agent session parked on an approval: same `approval` kind as a local
+// gate approval, but its action opens the orchestration chat window instead of
+// the local approval surface.
+const remoteApproval: AttentionItem = {
+ id: 'remote-approval:h-remote',
+ kind: 'approval',
+ instanceId: 'h-remote',
+ title: 'Claude · deploy',
+ summary: 'approve `rm -rf build/`',
+ action: { type: 'open-session', sessionId: 'h-remote' },
+ createdAt: '2026-07-06T12:00:00Z',
+};
+
const needsInput: AttentionItem = {
id: 'needs-input:run-1',
kind: 'needs-input',
@@ -50,6 +63,21 @@ describe('AttentionQueueItem', () => {
expect(onAction).toHaveBeenCalledWith({ type: 'approval', requestId: 'req-1' });
});
+ it('renders a remote approval (approval kind) whose action opens its session', () => {
+ const onAction = vi.fn();
+ render();
+ const row = screen.getByTestId('attention-item-remote-approval:h-remote');
+ expect(row).toHaveAttribute('data-kind', 'approval');
+ // Approval kind → the amber "Review" verb, and the summary is shown.
+ expect(screen.getByText('Claude · deploy')).toBeInTheDocument();
+ expect(screen.getByText('approve `rm -rf build/`')).toBeInTheDocument();
+ const action = screen.getByTestId('attention-item-action');
+ expect(action).toHaveTextContent('tinyplaceOrchestration.attention.review');
+ // Clicking routes the open-session action so the tab opens the chat window.
+ fireEvent.click(action);
+ expect(onAction).toHaveBeenCalledWith({ type: 'open-session', sessionId: 'h-remote' });
+ });
+
it('renders a needs-input row with the Open action carrying its thread', () => {
const onAction = vi.fn();
render();
diff --git a/app/src/components/intelligence/OrchestrationChatPrimitives.test.tsx b/app/src/components/intelligence/OrchestrationChatPrimitives.test.tsx
index 7ba752e87..e43280610 100644
--- a/app/src/components/intelligence/OrchestrationChatPrimitives.test.tsx
+++ b/app/src/components/intelligence/OrchestrationChatPrimitives.test.tsx
@@ -74,4 +74,43 @@ describe('MessageBubble', () => {
render();
expect(screen.getByText('••••')).toHaveClass('text-content-muted');
});
+
+ it('renders a tool_call as a monospace command row with ▶ + tool name', () => {
+ const { container } = render(
+
+ );
+ expect(container.querySelector('[data-event-kind="tool_call"]')).not.toBeNull();
+ expect(screen.getByText('▶')).toBeInTheDocument();
+ expect(screen.getByText('Bash')).toBeInTheDocument();
+ expect(container.querySelector('p.font-mono')?.textContent).toBe('ls');
+ });
+
+ it('renders a tool_result with the ↳ glyph', () => {
+ render(
+
+ );
+ expect(screen.getByText('↳')).toBeInTheDocument();
+ });
+
+ it('renders agent_thinking italic + muted with the ∴ glyph', () => {
+ const { container } = render(
+
+ );
+ expect(container.querySelector('p.italic')).not.toBeNull();
+ expect(screen.getByText('∴')).toBeInTheDocument();
+ });
+
+ it('renders an error row with the ✕ glyph', () => {
+ render();
+ expect(screen.getByText('✕')).toBeInTheDocument();
+ });
+
+ it('falls back to the plain-dot style for a legacy v1 row (no eventKind)', () => {
+ const { container } = render();
+ expect(container.querySelector('[data-event-kind="v1"]')).not.toBeNull();
+ expect(container.querySelector('div.rounded-full')).not.toBeNull();
+ expect(container.querySelector('p.font-mono')).toBeNull();
+ });
});
diff --git a/app/src/components/intelligence/OrchestrationChatPrimitives.tsx b/app/src/components/intelligence/OrchestrationChatPrimitives.tsx
index e7790231f..ea3e7270d 100644
--- a/app/src/components/intelligence/OrchestrationChatPrimitives.tsx
+++ b/app/src/components/intelligence/OrchestrationChatPrimitives.tsx
@@ -77,19 +77,97 @@ export function ChatListButton({
);
}
+/**
+ * Per-kind presentation for a harness (v2) event row. Differentiates the typed
+ * stream (tool_call / tool_result / agent_thinking / approval_request / error)
+ * from plain agent/user messages so the orchestration thread reads like a live
+ * activity log rather than undifferentiated text. Legacy v1 rows (no
+ * `eventKind`) fall through to the default agent-message style.
+ */
+interface BubbleStyle {
+ /** Leading marker: a glyph when the kind has one, else a colored dot. */
+ dot: string;
+ glyph?: string;
+ /** Render the body in a monospace block (tool command / output). */
+ mono?: boolean;
+ /** Body text tone. */
+ tone: string;
+ /** Optional left-accent on the bubble. */
+ accent: string;
+}
+
+function bubbleStyle(kind: ChatMessage['eventKind']): BubbleStyle {
+ switch (kind) {
+ case 'tool_call':
+ return {
+ dot: 'text-ocean-500',
+ glyph: '▶',
+ mono: true,
+ tone: 'text-content',
+ accent: 'border-l-2 border-l-ocean-400',
+ };
+ case 'tool_result':
+ return {
+ dot: 'text-sage-500',
+ glyph: '↳',
+ mono: true,
+ tone: 'text-content-muted',
+ accent: 'border-l-2 border-l-sage-400',
+ };
+ case 'agent_thinking':
+ return {
+ dot: 'text-content-faint',
+ glyph: '∴',
+ tone: 'italic text-content-faint',
+ accent: '',
+ };
+ case 'approval_request':
+ return {
+ dot: 'text-amber-500',
+ glyph: '⚠',
+ tone: 'text-content',
+ accent: 'border-l-2 border-l-amber-400',
+ };
+ case 'error':
+ return {
+ dot: 'text-coral-500',
+ glyph: '✕',
+ tone: 'text-coral-600 dark:text-coral-300',
+ accent: 'border-l-2 border-l-coral-400',
+ };
+ case 'user_prompt':
+ return { dot: 'text-sage-500', tone: 'text-content', accent: '' };
+ default:
+ return { dot: 'text-ocean-500', tone: 'text-content', accent: '' };
+ }
+}
+
export function MessageBubble({ message }: { message: ChatMessage }): ReactElement {
+ const style = bubbleStyle(message.eventKind);
return (
-
-
-
+
+ {style.glyph ? (
+
{style.glyph}
+ ) : (
+
+ )}
+
{message.from}
+ {message.toolName ? (
+
+ {message.toolName}
+
+ ) : null}
{formatTime(message.timestamp)}
+ className={`mt-1 max-h-64 overflow-y-auto whitespace-pre-wrap break-words ${
+ style.mono ? 'font-mono text-xs' : 'text-sm'
+ } ${message.encrypted ? 'text-content-muted' : style.tone}`}>
{message.body}
diff --git a/app/src/components/intelligence/OrchestrationSidebar.test.tsx b/app/src/components/intelligence/OrchestrationSidebar.test.tsx
index 364238412..8d09b9999 100644
--- a/app/src/components/intelligence/OrchestrationSidebar.test.tsx
+++ b/app/src/components/intelligence/OrchestrationSidebar.test.tsx
@@ -37,6 +37,9 @@ const props = (over: Partial
): OrchestrationSidebarPr
steeringText: null,
selfIdentity: null,
identityLoading: false,
+ onPublishIdentity: vi.fn(),
+ publishingIdentity: false,
+ publishIdentityError: null,
attentionQueue: null,
attentionLoading: false,
onAttentionAction: vi.fn(),
diff --git a/app/src/components/intelligence/OrchestrationSidebar.tsx b/app/src/components/intelligence/OrchestrationSidebar.tsx
index 7dd34823f..9d19ca0d8 100644
--- a/app/src/components/intelligence/OrchestrationSidebar.tsx
+++ b/app/src/components/intelligence/OrchestrationSidebar.tsx
@@ -38,6 +38,9 @@ export interface OrchestrationSidebarProps {
steeringText: string | null;
selfIdentity: SelfIdentity | null;
identityLoading: boolean;
+ onPublishIdentity: () => void;
+ publishingIdentity: boolean;
+ publishIdentityError: string | null;
attentionQueue: AttentionQueue | null;
attentionLoading: boolean;
onAttentionAction: (action: AttentionAction) => void;
@@ -72,6 +75,9 @@ export default function OrchestrationSidebar({
steeringText,
selfIdentity,
identityLoading,
+ onPublishIdentity,
+ publishingIdentity,
+ publishIdentityError,
attentionQueue,
attentionLoading,
onAttentionAction,
@@ -146,7 +152,13 @@ export default function OrchestrationSidebar({
) : null}
-
+
{
).toBeInTheDocument();
});
+ const undiscoverable: SelfIdentity = {
+ agentId: 'addrWithNoCardPublishedYet',
+ handles: [{ username: 'openhuman', primary: true }],
+ primaryHandle: 'openhuman',
+ cardPublished: false,
+ keyPublished: false,
+ discoverable: false,
+ };
+
+ it('renders a "Make discoverable" button that fires onPublish while undiscoverable', () => {
+ const onPublish = vi.fn();
+ render();
+ const btn = screen.getByTestId('tinyplace-self-identity-publish');
+ expect(btn).toHaveTextContent('tinyplaceOrchestration.identity.makeDiscoverable');
+ fireEvent.click(btn);
+ expect(onPublish).toHaveBeenCalledTimes(1);
+ });
+
+ it('disables the button and shows the publishing label while a publish is in flight', () => {
+ render(
+
+ );
+ const btn = screen.getByTestId('tinyplace-self-identity-publish');
+ expect(btn).toBeDisabled();
+ expect(btn).toHaveTextContent('tinyplaceOrchestration.identity.publishing');
+ });
+
+ it('surfaces a publish error under the button', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('tinyplace-self-identity-publish-error')).toHaveTextContent(
+ 'tinyplaceOrchestration.identity.publishFailed'
+ );
+ });
+
+ it('shows no "make discoverable" button once discoverable', () => {
+ render();
+ expect(screen.queryByTestId('tinyplace-self-identity-publish')).toBeNull();
+ });
+
+ it('offers a "Republish keys" action while discoverable that fires onPublish', () => {
+ const onPublish = vi.fn();
+ render();
+ const btn = screen.getByTestId('tinyplace-self-identity-republish');
+ expect(btn).toHaveTextContent('tinyplaceOrchestration.identity.republish');
+ fireEvent.click(btn);
+ expect(onPublish).toHaveBeenCalledTimes(1);
+ });
+
+ it('omits the publish button when no onPublish handler is provided', () => {
+ render();
+ expect(screen.queryByTestId('tinyplace-self-identity-publish')).toBeNull();
+ });
+
it('copies the address to the clipboard on click', async () => {
render();
fireEvent.click(screen.getByTestId('tinyplace-self-identity-copy'));
diff --git a/app/src/components/intelligence/SelfIdentityCard.tsx b/app/src/components/intelligence/SelfIdentityCard.tsx
index 59ad8b69a..8a824d1a7 100644
--- a/app/src/components/intelligence/SelfIdentityCard.tsx
+++ b/app/src/components/intelligence/SelfIdentityCard.tsx
@@ -20,6 +20,16 @@ import type { SelfIdentity } from '../../lib/orchestration/orchestrationClient';
export interface SelfIdentityCardProps {
identity: SelfIdentity | null;
loading: boolean;
+ /**
+ * Publish (or refresh) this agent's directory card + Signal key so peers can
+ * DM it. Rendered only while undiscoverable. The parent owns the RPC + the
+ * identity refresh; the card just drives the button state.
+ */
+ onPublish?: () => void;
+ /** True while {@link onPublish} is in flight — disables the button. */
+ publishing?: boolean;
+ /** Non-null when the last publish attempt failed — surfaces under the button. */
+ publishError?: string | null;
}
function shortAddress(address: string): string {
@@ -30,6 +40,9 @@ function shortAddress(address: string): string {
export default function SelfIdentityCard({
identity,
loading,
+ onPublish,
+ publishing,
+ publishError,
}: SelfIdentityCardProps): React.ReactElement {
const { t } = useT();
const [copied, setCopied] = useState(false);
@@ -124,10 +137,53 @@ export default function SelfIdentityCard({
+ {identity.discoverable && onPublish ? (
+
+
+ {publishError ? (
+
+ {t('tinyplaceOrchestration.identity.publishFailed')}
+
+ ) : null}
+
+ ) : null}
+
{!identity.discoverable ? (
-
- {t('tinyplaceOrchestration.identity.undiscoverableHint')}
-
+
+
+ {t('tinyplaceOrchestration.identity.undiscoverableHint')}
+
+ {onPublish ? (
+
+ ) : null}
+ {publishError ? (
+
+ {t('tinyplaceOrchestration.identity.publishFailed')}
+
+ ) : null}
+
) : null}
);
diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx
index 4f2edea1e..726b33d60 100644
--- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx
+++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx
@@ -31,6 +31,7 @@ vi.mock('../../lib/orchestration/orchestrationClient', async importOriginal => {
markRead: vi.fn(),
status: vi.fn(),
selfIdentity: vi.fn(),
+ publishIdentity: vi.fn(),
relayInfo: vi.fn(),
attention: vi.fn(),
},
diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx
index f394a280e..20876685a 100644
--- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx
+++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx
@@ -69,6 +69,10 @@ export default function TinyPlaceOrchestrationTab() {
// the whole tab.
const [selfIdentity, setSelfIdentity] = useState
(null);
const [identityLoading, setIdentityLoading] = useState(true);
+ // "Make discoverable" remediation: publishes the directory card + Signal key
+ // when the identity card reports the agent is un-messageable.
+ const [publishingIdentity, setPublishingIdentity] = useState(false);
+ const [publishIdentityError, setPublishIdentityError] = useState(null);
const [relayInfo, setRelayInfo] = useState(null);
// The aggregated "needs you" queue (approvals + blocked runs + unread). Read
// independently of chats so a failure leaves the zone empty, never the tab.
@@ -152,6 +156,28 @@ export default function TinyPlaceOrchestrationTab() {
setIdentityLoading(false);
}, []);
+ // Publish (or refresh) the directory card + Signal key, then adopt the fresh
+ // identity the RPC echoes back so the card flips to "discoverable" without a
+ // separate reload. A failure surfaces on the card, not the whole tab.
+ const publishIdentity = useCallback(async () => {
+ debug('[tinyplace-orchestration] publish identity entry');
+ setPublishingIdentity(true);
+ setPublishIdentityError(null);
+ try {
+ const identity = await orchestrationClient.publishIdentity();
+ if (!mountedRef.current) return;
+ debug('[tinyplace-orchestration] publish identity ok discoverable=%s', identity.discoverable);
+ setSelfIdentity(identity);
+ } catch (error) {
+ if (!mountedRef.current) return;
+ const message = error instanceof Error ? error.message : String(error);
+ debug('[tinyplace-orchestration] publish identity error %s', message);
+ setPublishIdentityError(message);
+ } finally {
+ if (mountedRef.current) setPublishingIdentity(false);
+ }
+ }, []);
+
const loadAttention = useCallback(async () => {
debug('[tinyplace-orchestration] attention load entry');
try {
@@ -348,6 +374,9 @@ export default function TinyPlaceOrchestrationTab() {
steeringText={steeringText}
selfIdentity={selfIdentity}
identityLoading={identityLoading}
+ onPublishIdentity={publishIdentity}
+ publishingIdentity={publishingIdentity}
+ publishIdentityError={publishIdentityError}
attentionQueue={attentionQueue}
attentionLoading={attentionLoading}
onAttentionAction={handleAttentionAction}
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 4e2d3b7ae..be364a039 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -299,7 +299,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'قابل للاكتشاف',
'tinyplaceOrchestration.identity.undiscoverable': 'غير قابل للاكتشاف',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'سجّل @handle حتى يتمكن الآخرون من مراسلتك.',
+ 'انشر بطاقة الدليل ومفتاح التشفير حتى يتمكن الآخرون من مراسلتك.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'اجعله قابلاً للاكتشاف',
+ 'tinyplaceOrchestration.identity.republish': 'إعادة نشر المفاتيح',
+ 'tinyplaceOrchestration.identity.publishing': 'جارٍ النشر…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'فشل النشر — أعد المحاولة',
'tinyplaceOrchestration.identity.card': 'بطاقة الدليل',
'tinyplaceOrchestration.identity.key': 'مفتاح التشفير',
'tinyplaceOrchestration.identity.published': 'منشور',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index a31f61442..d59283f50 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -306,7 +306,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'খুঁজে পাওয়া যায়',
'tinyplaceOrchestration.identity.undiscoverable': 'খুঁজে পাওয়া যায় না',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'একটি @handle নিবন্ধন করুন যাতে অন্য এজেন্টরা আপনাকে বার্তা পাঠাতে পারে।',
+ 'আপনার ডিরেক্টরি কার্ড ও এনক্রিপশন কী প্রকাশ করুন যাতে অন্য এজেন্টরা আপনাকে বার্তা পাঠাতে পারে।',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'আবিষ্কারযোগ্য করুন',
+ 'tinyplaceOrchestration.identity.republish': 'কী পুনঃপ্রকাশ করুন',
+ 'tinyplaceOrchestration.identity.publishing': 'প্রকাশ করা হচ্ছে…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'প্রকাশ ব্যর্থ হয়েছে — আবার চেষ্টা করুন',
'tinyplaceOrchestration.identity.card': 'ডিরেক্টরি কার্ড',
'tinyplaceOrchestration.identity.key': 'এনক্রিপশন কী',
'tinyplaceOrchestration.identity.published': 'প্রকাশিত',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 13107b914..3f3d8cb6f 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -313,7 +313,12 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'Auffindbar',
'tinyplaceOrchestration.identity.undiscoverable': 'Nicht auffindbar',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'Registriere ein @handle, damit Peers dir schreiben können.',
+ 'Veröffentliche deine Verzeichniskarte und deinen Verschlüsselungsschlüssel, damit Peers dir schreiben können.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'Auffindbar machen',
+ 'tinyplaceOrchestration.identity.republish': 'Schlüssel neu veröffentlichen',
+ 'tinyplaceOrchestration.identity.publishing': 'Wird veröffentlicht…',
+ 'tinyplaceOrchestration.identity.publishFailed':
+ 'Veröffentlichung fehlgeschlagen — erneut versuchen',
'tinyplaceOrchestration.identity.card': 'Verzeichniskarte',
'tinyplaceOrchestration.identity.key': 'Verschlüsselungsschlüssel',
'tinyplaceOrchestration.identity.published': 'Veröffentlicht',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 0210139d9..a805d7569 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -4248,7 +4248,11 @@ const en: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'Discoverable',
'tinyplaceOrchestration.identity.undiscoverable': 'Not discoverable',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'Register a @handle so peers can message you.',
+ 'Publish your directory card + encryption key so peers can message you.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'Make discoverable',
+ 'tinyplaceOrchestration.identity.republish': 'Republish keys',
+ 'tinyplaceOrchestration.identity.publishing': 'Publishing…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'Publish failed — try again',
'tinyplaceOrchestration.identity.card': 'Directory card',
'tinyplaceOrchestration.identity.key': 'Encryption key',
'tinyplaceOrchestration.identity.published': 'Published',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 6f77c60ab..2cf8e7ad5 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -310,7 +310,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'Detectable',
'tinyplaceOrchestration.identity.undiscoverable': 'No detectable',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'Registra un @handle para que otros agentes puedan escribirte.',
+ 'Publica tu tarjeta de directorio y tu clave de cifrado para que otros agentes puedan escribirte.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'Hacer detectable',
+ 'tinyplaceOrchestration.identity.republish': 'Volver a publicar claves',
+ 'tinyplaceOrchestration.identity.publishing': 'Publicando…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'Error al publicar — inténtalo de nuevo',
'tinyplaceOrchestration.identity.card': 'Tarjeta de directorio',
'tinyplaceOrchestration.identity.key': 'Clave de cifrado',
'tinyplaceOrchestration.identity.published': 'Publicado',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index 457d9ed8c..87b5aae97 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -310,7 +310,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'Découvrable',
'tinyplaceOrchestration.identity.undiscoverable': 'Non découvrable',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'Enregistre un @handle pour que les pairs puissent te contacter.',
+ 'Publie ta fiche d’annuaire et ta clé de chiffrement pour que les pairs puissent te contacter.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'Rendre découvrable',
+ 'tinyplaceOrchestration.identity.republish': 'Republier les clés',
+ 'tinyplaceOrchestration.identity.publishing': 'Publication…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'Échec de la publication — réessaie',
'tinyplaceOrchestration.identity.card': 'Fiche d’annuaire',
'tinyplaceOrchestration.identity.key': 'Clé de chiffrement',
'tinyplaceOrchestration.identity.published': 'Publié',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index 7a3973055..d829f0ae7 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -306,7 +306,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'खोजने योग्य',
'tinyplaceOrchestration.identity.undiscoverable': 'खोजने योग्य नहीं',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'एक @handle पंजीकृत करें ताकि अन्य एजेंट आपको संदेश भेज सकें।',
+ 'अपना डायरेक्टरी कार्ड और एन्क्रिप्शन कुंजी प्रकाशित करें ताकि अन्य एजेंट आपको संदेश भेज सकें।',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'खोजने योग्य बनाएँ',
+ 'tinyplaceOrchestration.identity.republish': 'कुंजियाँ पुनः प्रकाशित करें',
+ 'tinyplaceOrchestration.identity.publishing': 'प्रकाशित हो रहा है…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'प्रकाशन विफल — पुनः प्रयास करें',
'tinyplaceOrchestration.identity.card': 'डायरेक्टरी कार्ड',
'tinyplaceOrchestration.identity.key': 'एन्क्रिप्शन कुंजी',
'tinyplaceOrchestration.identity.published': 'प्रकाशित',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 1a81cda28..ae5122164 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -307,7 +307,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'Dapat ditemukan',
'tinyplaceOrchestration.identity.undiscoverable': 'Tidak dapat ditemukan',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'Daftarkan @handle agar rekan dapat mengirimi Anda pesan.',
+ 'Terbitkan kartu direktori dan kunci enkripsi Anda agar rekan dapat mengirimi Anda pesan.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'Jadikan dapat ditemukan',
+ 'tinyplaceOrchestration.identity.republish': 'Terbitkan ulang kunci',
+ 'tinyplaceOrchestration.identity.publishing': 'Menerbitkan…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'Gagal menerbitkan — coba lagi',
'tinyplaceOrchestration.identity.card': 'Kartu direktori',
'tinyplaceOrchestration.identity.key': 'Kunci enkripsi',
'tinyplaceOrchestration.identity.published': 'Diterbitkan',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index dbacdaee7..3e7d9f0f6 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -310,7 +310,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'Rilevabile',
'tinyplaceOrchestration.identity.undiscoverable': 'Non rilevabile',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'Registra un @handle così altri agenti possono scriverti.',
+ 'Pubblica la tua scheda directory e la tua chiave di crittografia così altri agenti possono scriverti.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'Rendi rilevabile',
+ 'tinyplaceOrchestration.identity.republish': 'Ripubblica chiavi',
+ 'tinyplaceOrchestration.identity.publishing': 'Pubblicazione…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'Pubblicazione non riuscita — riprova',
'tinyplaceOrchestration.identity.card': 'Scheda directory',
'tinyplaceOrchestration.identity.key': 'Chiave di crittografia',
'tinyplaceOrchestration.identity.published': 'Pubblicato',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index 2ad9776fa..cdedbb1fc 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -303,7 +303,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': '검색 가능',
'tinyplaceOrchestration.identity.undiscoverable': '검색 불가',
'tinyplaceOrchestration.identity.undiscoverableHint':
- '다른 에이전트가 메시지를 보낼 수 있도록 @handle을 등록하세요.',
+ '다른 에이전트가 메시지를 보낼 수 있도록 디렉터리 카드와 암호화 키를 게시하세요.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': '검색 가능하게 만들기',
+ 'tinyplaceOrchestration.identity.republish': '키 다시 게시',
+ 'tinyplaceOrchestration.identity.publishing': '게시 중…',
+ 'tinyplaceOrchestration.identity.publishFailed': '게시 실패 — 다시 시도하세요',
'tinyplaceOrchestration.identity.card': '디렉터리 카드',
'tinyplaceOrchestration.identity.key': '암호화 키',
'tinyplaceOrchestration.identity.published': '게시됨',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 25b13543f..f546e0572 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -311,7 +311,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'Wykrywalny',
'tinyplaceOrchestration.identity.undiscoverable': 'Niewykrywalny',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'Zarejestruj @handle, aby inni mogli do Ciebie pisać.',
+ 'Opublikuj swoją kartę katalogu i klucz szyfrowania, aby inni mogli do Ciebie pisać.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'Ustaw jako wykrywalny',
+ 'tinyplaceOrchestration.identity.republish': 'Opublikuj ponownie klucze',
+ 'tinyplaceOrchestration.identity.publishing': 'Publikowanie…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'Publikacja nie powiodła się — spróbuj ponownie',
'tinyplaceOrchestration.identity.card': 'Karta katalogu',
'tinyplaceOrchestration.identity.key': 'Klucz szyfrowania',
'tinyplaceOrchestration.identity.published': 'Opublikowano',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index bd64f6a1c..a5c4a12aa 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -309,7 +309,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'Descobrível',
'tinyplaceOrchestration.identity.undiscoverable': 'Não descobrível',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'Registre um @handle para que outros agentes possam lhe enviar mensagens.',
+ 'Publique seu cartão de diretório e sua chave de criptografia para que outros agentes possam lhe enviar mensagens.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'Tornar descobrível',
+ 'tinyplaceOrchestration.identity.republish': 'Republicar chaves',
+ 'tinyplaceOrchestration.identity.publishing': 'Publicando…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'Falha ao publicar — tente novamente',
'tinyplaceOrchestration.identity.card': 'Cartão de diretório',
'tinyplaceOrchestration.identity.key': 'Chave de criptografia',
'tinyplaceOrchestration.identity.published': 'Publicado',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index a6959c9ad..702823bf1 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -311,7 +311,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': 'Обнаруживаемый',
'tinyplaceOrchestration.identity.undiscoverable': 'Не обнаруживается',
'tinyplaceOrchestration.identity.undiscoverableHint':
- 'Зарегистрируйте @handle, чтобы другие агенты могли писать вам.',
+ 'Опубликуйте карточку каталога и ключ шифрования, чтобы другие агенты могли писать вам.',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': 'Сделать обнаруживаемым',
+ 'tinyplaceOrchestration.identity.republish': 'Опубликовать ключи заново',
+ 'tinyplaceOrchestration.identity.publishing': 'Публикация…',
+ 'tinyplaceOrchestration.identity.publishFailed': 'Не удалось опубликовать — попробуйте снова',
'tinyplaceOrchestration.identity.card': 'Карточка каталога',
'tinyplaceOrchestration.identity.key': 'Ключ шифрования',
'tinyplaceOrchestration.identity.published': 'Опубликовано',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index 4eced2c31..37574dea4 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -287,7 +287,11 @@ const messages: TranslationMap = {
'tinyplaceOrchestration.identity.discoverable': '可被发现',
'tinyplaceOrchestration.identity.undiscoverable': '不可被发现',
'tinyplaceOrchestration.identity.undiscoverableHint':
- '注册一个 @handle,以便其他代理可以给你发消息。',
+ '发布你的目录卡片和加密密钥,以便其他代理可以给你发消息。',
+ 'tinyplaceOrchestration.identity.makeDiscoverable': '设为可被发现',
+ 'tinyplaceOrchestration.identity.republish': '重新发布密钥',
+ 'tinyplaceOrchestration.identity.publishing': '正在发布…',
+ 'tinyplaceOrchestration.identity.publishFailed': '发布失败 — 请重试',
'tinyplaceOrchestration.identity.card': '目录卡片',
'tinyplaceOrchestration.identity.key': '加密密钥',
'tinyplaceOrchestration.identity.published': '已发布',
diff --git a/app/src/lib/orchestration/orchestrationClient.ts b/app/src/lib/orchestration/orchestrationClient.ts
index dc2efa8ec..27f7af6cd 100644
--- a/app/src/lib/orchestration/orchestrationClient.ts
+++ b/app/src/lib/orchestration/orchestrationClient.ts
@@ -50,6 +50,24 @@ export interface SessionSummary {
pinned: boolean;
}
+/**
+ * Typed kind of a harness (v2) event, mirroring the SDK's
+ * `tinyplace.harness.session.v2` stream. Absent on legacy v1 rows (which carry
+ * only role + body). The core hides `status`/`lifecycle`/`unknown` from the
+ * thread, so those should not normally reach the renderer.
+ */
+export type HarnessEventKind =
+ | 'user_prompt'
+ | 'agent_message'
+ | 'agent_thinking'
+ | 'tool_call'
+ | 'tool_result'
+ | 'approval_request'
+ | 'status'
+ | 'lifecycle'
+ | 'error'
+ | 'unknown';
+
export interface OrchestrationMessage {
id: string;
agentId: string;
@@ -59,6 +77,12 @@ export interface OrchestrationMessage {
body: string;
timestamp: string;
seq: number;
+ /** Typed event kind for v2 harness streams; absent on v1 text rows. */
+ eventKind?: HarnessEventKind;
+ /** Raw harness tool name (e.g. "Bash") on tool_call / tool_result rows. */
+ toolName?: string;
+ /** Correlation id shared by a tool_call and its tool_result. */
+ callId?: string;
}
export interface OrchestrationSteering {
@@ -258,6 +282,15 @@ export const orchestrationClient = {
*/
selfIdentity: () => call('openhuman.orchestration_self_identity', {}),
+ /**
+ * Make this agent discoverable: publish (or refresh) its directory card + Signal
+ * encryption key for the wallet's current identity, then return the updated
+ * {@link SelfIdentity}. No @handle registration and no payment — it repairs the
+ * common "has an identity but card/key aren't published" gap that makes every
+ * inbound DM 404. Powers the SelfIdentityCard's "Make discoverable" action.
+ */
+ publishIdentity: () => call('openhuman.orchestration_publish_identity', {}),
+
/** The relay endpoint + network label the core is talking to (RelayBadge). */
relayInfo: () => call('openhuman.orchestration_relay_info', {}),
};
diff --git a/app/src/lib/orchestration/useOrchestrationChats.ts b/app/src/lib/orchestration/useOrchestrationChats.ts
index 4719450ef..d04d899c9 100644
--- a/app/src/lib/orchestration/useOrchestrationChats.ts
+++ b/app/src/lib/orchestration/useOrchestrationChats.ts
@@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { socketService } from '../../services/socketService';
import {
+ type HarnessEventKind,
orchestrationClient,
type OrchestrationMessage,
type OrchestrationMessageEvent,
@@ -37,6 +38,12 @@ export interface ChatMessage {
body: string;
timestamp: string;
encrypted: boolean;
+ /** Typed harness (v2) event kind; absent on legacy v1 text rows. */
+ eventKind?: HarnessEventKind;
+ /** Tool name on tool_call / tool_result rows. */
+ toolName?: string;
+ /** Correlation id linking a tool_call to its tool_result. */
+ callId?: string;
}
export interface ChatWindow {
@@ -84,6 +91,9 @@ function mapMessage(message: OrchestrationMessage): ChatMessage {
body: message.body,
timestamp: message.timestamp,
encrypted: false,
+ ...(message.eventKind ? { eventKind: message.eventKind } : {}),
+ ...(message.toolName ? { toolName: message.toolName } : {}),
+ ...(message.callId ? { callId: message.callId } : {}),
};
}
diff --git a/src/openhuman/orchestration/attention.rs b/src/openhuman/orchestration/attention.rs
index 1f830d370..78279ad5d 100644
--- a/src/openhuman/orchestration/attention.rs
+++ b/src/openhuman/orchestration/attention.rs
@@ -141,6 +141,25 @@ pub(crate) struct UnreadSignal {
pub last_message_at: Option,
}
+/// A REMOTE agent session parked on a tool-approval decision. Phase 1 persists
+/// `status.state = "waiting_approval"` on the session (plus the prompt in
+/// `current_detail` and the in-flight `active_call_id`) when a v2
+/// `approval_request` event arrives from a peer harness. Unlike [`ApprovalSignal`]
+/// — which is a call parked on THIS core's local approval gate — this is a
+/// remote instance blocked on the user; acting on it opens the orchestration
+/// chat window so the user can steer the reply.
+pub(crate) struct RemoteApprovalSignal {
+ pub session_id: String,
+ /// Human-friendly session label; falls back to the session id at the builder.
+ pub label: Option,
+ /// The approval prompt / current-activity line (PII-safe summary text).
+ pub detail: Option,
+ /// The in-flight tool call id awaiting the decision, when known. Carried for
+ /// parity with the persisted run-state; not currently surfaced on the wire.
+ pub active_call_id: Option,
+ pub created_at: Option,
+}
+
// ── Builders ────────────────────────────────────────────────────────────────
fn approval_item(sig: ApprovalSignal) -> AttentionItem {
@@ -194,18 +213,44 @@ fn unread_item(sig: UnreadSignal) -> AttentionItem {
}
}
+fn remote_approval_item(sig: RemoteApprovalSignal) -> AttentionItem {
+ let title = sig.label.unwrap_or_else(|| sig.session_id.clone());
+ // Namespaced apart from local approvals (`approval:`) so a session
+ // id can never collide with a request id in the renderer's list keys, even
+ // though both surface as the same `Approval` kind.
+ AttentionItem {
+ id: format!("remote-approval:{}", sig.session_id),
+ kind: AttentionKind::Approval,
+ title,
+ summary: sig.detail,
+ count: None,
+ action: AttentionAction::OpenSession {
+ session_id: sig.session_id.clone(),
+ },
+ instance_id: sig.session_id,
+ created_at: sig.created_at,
+ }
+}
+
// ── Assembly ────────────────────────────────────────────────────────────────
-/// Fold the three signal sources into one ordered queue. Unread signals with a
+/// Fold the four signal sources into one ordered queue. Unread signals with a
/// zero count are dropped (nothing to surface). Ordering is by kind urgency,
/// then newest-first within a kind, then id for a stable total order.
+///
+/// `remote_approvals` are peer sessions parked on an approval decision (Phase 1's
+/// persisted `waiting_approval` run-state). They share the `Approval` kind with
+/// the local-gate `approvals`, so they lead the queue and are counted under
+/// `counts.approvals` — they ARE approvals, just remote ones.
pub(crate) fn assemble_attention(
approvals: Vec,
needs_input: Vec,
unread: Vec,
+ remote_approvals: Vec,
) -> AttentionQueue {
let mut items: Vec = Vec::new();
items.extend(approvals.into_iter().map(approval_item));
+ items.extend(remote_approvals.into_iter().map(remote_approval_item));
items.extend(needs_input.into_iter().map(needs_input_item));
items.extend(unread.into_iter().filter(|s| s.unread > 0).map(unread_item));
@@ -281,6 +326,28 @@ pub(crate) fn needs_input_from_command_center(
.unwrap_or_default()
}
+/// Map orchestration sessions into remote-approval signals — keeping only those
+/// whose persisted v2 run-state is `waiting_approval` (Phase 1 stamps this when a
+/// peer harness emits an `approval_request`). Pure: the handler passes the
+/// already-fetched session list. `detail` carries the PII-safe approval prompt
+/// (`current_detail`); the deep link opens the session window (`last_message_at`
+/// stamps the newest-first tie-break).
+pub(crate) fn remote_approval_signals(
+ sessions: Vec,
+) -> Vec {
+ sessions
+ .into_iter()
+ .filter(|s| s.status_state.as_deref() == Some("waiting_approval"))
+ .map(|s| RemoteApprovalSignal {
+ session_id: s.session_id,
+ label: s.label,
+ detail: s.current_detail,
+ active_call_id: s.active_call_id,
+ created_at: Some(s.last_message_at),
+ })
+ .collect()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -296,7 +363,7 @@ mod tests {
#[test]
fn empty_sources_yield_empty_queue_with_zero_counts() {
- let q = assemble_attention(vec![], vec![], vec![]);
+ let q = assemble_attention(vec![], vec![], vec![], vec![]);
assert!(q.items.is_empty());
assert_eq!(
q.counts,
@@ -326,6 +393,7 @@ mod tests {
unread: 3,
last_message_at: Some("2026-07-06T12:00:00Z".into()),
}],
+ vec![],
);
let kinds: Vec = q.items.iter().map(|i| i.kind).collect();
assert_eq!(
@@ -357,6 +425,7 @@ mod tests {
],
vec![],
vec![],
+ vec![],
);
let ids: Vec<&str> = q.items.iter().map(|i| i.instance_id.as_str()).collect();
assert_eq!(ids, vec!["new", "mid", "old"]);
@@ -381,6 +450,7 @@ mod tests {
last_message_at: Some("2026-07-06T12:00:00Z".into()),
},
],
+ vec![],
);
assert_eq!(q.items.len(), 1);
assert_eq!(q.items[0].instance_id, "loud");
@@ -416,6 +486,7 @@ mod tests {
unread: 2,
last_message_at: None,
}],
+ vec![],
);
let by_id = |id: &str| q.items.iter().find(|i| i.id == id).unwrap().clone();
@@ -505,6 +576,7 @@ mod tests {
unread: 1,
last_message_at: None,
}],
+ vec![],
);
assert_eq!(q.items[0].title, "h-xyz");
}
@@ -613,4 +685,150 @@ mod tests {
}]));
assert!(signals.is_empty());
}
+
+ // ── remote-approval mapping ─────────────────────────────────────────────
+
+ use crate::openhuman::orchestration::types::OrchestrationSession;
+
+ fn session(id: &str, state: Option<&str>) -> OrchestrationSession {
+ OrchestrationSession {
+ session_id: id.into(),
+ agent_id: "@peer".into(),
+ source: "claude".into(),
+ last_seq: 1,
+ created_at: "2026-07-06T00:00:00Z".into(),
+ last_message_at: "2026-07-06T12:00:00Z".into(),
+ status_state: state.map(str::to_string),
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn remote_approval_signals_keep_only_waiting_approval_sessions() {
+ let waiting = OrchestrationSession {
+ label: Some("Claude · deploy".into()),
+ current_detail: Some("approve `rm -rf build/`".into()),
+ active_call_id: Some("call-9".into()),
+ ..session("h-waiting", Some("waiting_approval"))
+ };
+ let signals = remote_approval_signals(vec![
+ waiting,
+ session("h-running", Some("running")),
+ session("h-idle", Some("idle")),
+ session("h-legacy", None),
+ ]);
+ assert_eq!(signals.len(), 1, "only the waiting_approval session maps");
+ let s = &signals[0];
+ assert_eq!(s.session_id, "h-waiting");
+ assert_eq!(s.label.as_deref(), Some("Claude · deploy"));
+ assert_eq!(s.detail.as_deref(), Some("approve `rm -rf build/`"));
+ assert_eq!(s.active_call_id.as_deref(), Some("call-9"));
+ assert_eq!(s.created_at.as_deref(), Some("2026-07-06T12:00:00Z"));
+ }
+
+ fn remote(session_id: &str, at: &str) -> RemoteApprovalSignal {
+ RemoteApprovalSignal {
+ session_id: session_id.into(),
+ label: Some("Claude · deploy".into()),
+ detail: Some("approve `rm -rf build/`".into()),
+ active_call_id: Some("call-9".into()),
+ created_at: Some(at.into()),
+ }
+ }
+
+ #[test]
+ fn remote_approval_surfaces_as_approval_kind_ahead_of_other_kinds() {
+ let q = assemble_attention(
+ vec![],
+ vec![NeedsInputSignal {
+ run_id: "run-1".into(),
+ title: "researcher".into(),
+ summary: None,
+ thread_id: Some("t-1".into()),
+ updated_at: Some("2026-07-06T11:00:00Z".into()),
+ }],
+ vec![UnreadSignal {
+ session_id: "h-2".into(),
+ label: None,
+ unread: 2,
+ last_message_at: Some("2026-07-06T13:00:00Z".into()),
+ }],
+ vec![remote("h-remote", "2026-07-06T12:00:00Z")],
+ );
+ // The remote approval leads (Approval kind, priority 0).
+ assert_eq!(q.items[0].kind, AttentionKind::Approval);
+ assert_eq!(q.items[0].id, "remote-approval:h-remote");
+ assert_eq!(
+ q.items[0].action,
+ AttentionAction::OpenSession {
+ session_id: "h-remote".into()
+ }
+ );
+ assert_eq!(
+ q.items[0].summary.as_deref(),
+ Some("approve `rm -rf build/`")
+ );
+ assert_eq!(q.items[0].title, "Claude · deploy");
+ // Counted under approvals — a remote approval IS an approval.
+ assert_eq!(
+ q.counts,
+ AttentionCounts {
+ total: 3,
+ approvals: 1,
+ needs_input: 1,
+ unread: 1
+ }
+ );
+ }
+
+ #[test]
+ fn remote_approval_title_falls_back_to_session_id_when_unlabeled() {
+ let q = assemble_attention(
+ vec![],
+ vec![],
+ vec![],
+ vec![RemoteApprovalSignal {
+ session_id: "h-bare".into(),
+ label: None,
+ detail: None,
+ active_call_id: None,
+ created_at: None,
+ }],
+ );
+ assert_eq!(q.items.len(), 1);
+ assert_eq!(q.items[0].title, "h-bare");
+ assert_eq!(q.counts.approvals, 1);
+ }
+
+ #[test]
+ fn empty_remote_approvals_is_a_no_op() {
+ // A queue with only a local approval is unchanged by an empty remote vec.
+ let q = assemble_attention(
+ vec![approval("r1", "2026-07-06T10:00:00Z")],
+ vec![],
+ vec![],
+ vec![],
+ );
+ assert_eq!(q.items.len(), 1);
+ assert_eq!(q.items[0].id, "approval:r1");
+ assert_eq!(q.counts.approvals, 1);
+ }
+
+ #[test]
+ fn local_and_remote_approvals_coexist_newest_first() {
+ // Both are Approval kind; within the kind they order newest-first, so the
+ // later remote approval leads the earlier local one.
+ let q = assemble_attention(
+ vec![approval("local-old", "2026-07-06T09:00:00Z")],
+ vec![],
+ vec![],
+ vec![remote("remote-new", "2026-07-06T12:00:00Z")],
+ );
+ assert_eq!(q.counts.approvals, 2);
+ let ids: Vec<&str> = q.items.iter().map(|i| i.id.as_str()).collect();
+ assert_eq!(
+ ids,
+ vec!["remote-approval:remote-new", "approval:local-old"]
+ );
+ }
}
diff --git a/src/openhuman/orchestration/ingest.rs b/src/openhuman/orchestration/ingest.rs
index bb9f6c68d..2d096ae4f 100644
--- a/src/openhuman/orchestration/ingest.rs
+++ b/src/openhuman/orchestration/ingest.rs
@@ -14,7 +14,10 @@ use crate::openhuman::config::Config;
use crate::openhuman::tinyplace::{acknowledge_message, decrypt_envelope};
use super::store;
-use super::types::{ChatKind, OrchestrationMessage, OrchestrationSession, SessionEnvelopeV1};
+use super::types::{
+ ChatKind, HarnessEventKind, OrchestrationMessage, OrchestrationSession, SessionEnvelopeV1,
+ SessionEnvelopeV2,
+};
const LOG: &str = "orchestration";
@@ -71,9 +74,35 @@ struct ClassifiedMessage {
source: String,
label: Option,
workspace: Option,
+ /// The wire `line` (v1) / `event.seq` (v2). Retained for parity/debugging only
+ /// — persistence deliberately IGNORES it and stamps a store ordinal (#4583).
seq: i64,
body: String,
timestamp: String,
+ // ── v2 event shape (all `None`/false for v1 + Master) ────────────────────
+ /// v2 `event.kind`; drives the per-message render branch (Phase 2).
+ event_kind: Option,
+ /// v2 tool identity for `tool_call` / `approval_request`.
+ tool_name: Option,
+ /// v2 `call_id` correlating `tool_result` → `tool_call`.
+ call_id: Option,
+ /// v2 `status.state` (or a derived state for approval/lifecycle/error) written
+ /// onto the session row so `derive_status` reads a real run-state.
+ status_state: Option,
+ /// v2 `status.detail` → the session's `current_detail` (roster task line).
+ status_detail: Option,
+ /// v2 `status.active_call_id` → the session's `active_call_id`.
+ active_call_id: Option,
+ /// Whether this event advances the monotonic wake ordinal. Content events
+ /// (prompts/messages/thinking/tool_call/tool_result/approval/error) do; pure
+ /// session-state events (`status`/`lifecycle`/`unknown`) do NOT, so a status
+ /// ping never spuriously wakes the front-end graph.
+ advances_seq: bool,
+ /// True only for an authoritative `status` snapshot, which OWNS the session's
+ /// run-state columns and must be able to CLEAR `current_detail`/`active_call_id`
+ /// (e.g. `running_tool` → `idle`). Content events leave this false so the
+ /// COALESCE upsert preserves the last status instead of wiping it.
+ authoritative_status: bool,
}
/// True for streams that carry ciphertext DM envelopes worth ingesting.
@@ -101,49 +130,223 @@ fn order_prekey_bundles_first(messages: &mut [tinyplace::types::MessageEnvelope]
});
}
-/// Classify a decrypted DM: a harness envelope becomes a per-session message,
-/// anything else becomes a message in the peer's Master window. Pure.
+/// Classify a decrypted DM into the fields we persist. Version-dispatched: try a
+/// v2 harness envelope first, then a v1 envelope, else the peer's Master window.
+/// Both envelope versions discriminate on `envelope_version`, so the order is
+/// safe (a v1 body never matches v2 and vice-versa); this is the v1↔v2
+/// coexistence seam — both persist into the same session model. Pure.
fn classify_message(plaintext: String, fallback_timestamp: &str) -> ClassifiedMessage {
- match SessionEnvelopeV1::parse(&plaintext) {
- Some(env) => {
- // Compute the session key while `env` is still fully intact (before any
- // field moves below), since `session_key` borrows `&env`.
- let session_id = env.session_key();
- let label = (env.scope.scope_type == "folder").then(|| env.scope.key.clone());
- let workspace = (!env.scope.cwd.is_empty()).then(|| env.scope.cwd.clone());
- let timestamp = if env.message.timestamp.is_empty() {
- fallback_timestamp.to_string()
- } else {
- env.message.timestamp
- };
- ClassifiedMessage {
- chat_kind: ChatKind::Session,
- // Key on the single per-pair session id (the shared `wrapper_session_id`
- // both peers put on every message for a thread), so a reply threads back
- // into the same session. Falls back to `harness_session_id` for a legacy
- // envelope with no per-pair id. See `SessionEnvelopeV1::session_key`.
- session_id,
- role: env.message.role,
- source: env.harness.provider,
- label,
- workspace,
- seq: env.message.line,
- body: env.message.text,
- timestamp,
- }
- }
- None => ClassifiedMessage {
- chat_kind: ChatKind::Master,
- session_id: "master".to_string(),
- role: "user".to_string(),
- source: String::new(),
- label: None,
- workspace: None,
- seq: 0,
- body: plaintext,
- timestamp: fallback_timestamp.to_string(),
- },
+ if let Some(env) = SessionEnvelopeV2::parse(&plaintext) {
+ return classify_v2(env, fallback_timestamp);
}
+ if let Some(env) = SessionEnvelopeV1::parse(&plaintext) {
+ return classify_v1(env, fallback_timestamp);
+ }
+ // Not a harness envelope → a plain DM in the peer's Master window.
+ ClassifiedMessage {
+ chat_kind: ChatKind::Master,
+ session_id: "master".to_string(),
+ role: "user".to_string(),
+ source: String::new(),
+ label: None,
+ workspace: None,
+ seq: 0,
+ body: plaintext,
+ timestamp: fallback_timestamp.to_string(),
+ event_kind: None,
+ tool_name: None,
+ call_id: None,
+ status_state: None,
+ status_detail: None,
+ active_call_id: None,
+ advances_seq: true,
+ authoritative_status: false,
+ }
+}
+
+/// Classify a v1 harness envelope — the original per-session mapping, unchanged.
+fn classify_v1(env: SessionEnvelopeV1, fallback_timestamp: &str) -> ClassifiedMessage {
+ // Compute the session key while `env` is still fully intact (before any
+ // field moves below), since `session_key` borrows `&env`.
+ let session_id = env.session_key();
+ let label = (env.scope.scope_type == "folder").then(|| env.scope.key.clone());
+ let workspace = (!env.scope.cwd.is_empty()).then(|| env.scope.cwd.clone());
+ let timestamp = if env.message.timestamp.is_empty() {
+ fallback_timestamp.to_string()
+ } else {
+ env.message.timestamp
+ };
+ ClassifiedMessage {
+ chat_kind: ChatKind::Session,
+ // Key on the single per-pair session id (the shared `wrapper_session_id`
+ // both peers put on every message for a thread), so a reply threads back
+ // into the same session. Falls back to `harness_session_id` for a legacy
+ // envelope with no per-pair id. See `SessionEnvelopeV1::session_key`.
+ session_id,
+ role: env.message.role,
+ source: env.harness.provider,
+ label,
+ workspace,
+ seq: env.message.line,
+ body: env.message.text,
+ timestamp,
+ event_kind: None,
+ tool_name: None,
+ call_id: None,
+ status_state: None,
+ status_detail: None,
+ active_call_id: None,
+ advances_seq: true,
+ authoritative_status: false,
+ }
+}
+
+/// Classify a v2 harness envelope. Switches on `event.kind`, mapping each to the
+/// persisted fields (per the plan's mapping table). Content events become thread
+/// messages that advance the wake ordinal; `status`/`lifecycle`/`unknown` are
+/// session-state-only (they still persist a row for id-dedupe + ack, but do NOT
+/// advance the wake ordinal). Pure.
+fn classify_v2(env: SessionEnvelopeV2, fallback_timestamp: &str) -> ClassifiedMessage {
+ let session_id = env.session_key();
+ let label = (env.scope.scope_type == "folder").then(|| env.scope.key.clone());
+ let workspace = (!env.scope.cwd.is_empty()).then(|| env.scope.cwd.clone());
+ let source = env.harness.provider.clone();
+ let timestamp = if env.event.ts.is_empty() {
+ fallback_timestamp.to_string()
+ } else {
+ env.event.ts.clone()
+ };
+ let kind_str = env.event.kind.clone();
+ let wire_role = env.event.role.clone();
+ let seq = env.event.seq;
+
+ // Per-kind body + tool/status fields + wake disposition.
+ let mut b = V2Body::default();
+ match env.event.decoded() {
+ HarnessEventKind::UserPrompt(p) => {
+ b.body = p.text;
+ b.default_role = "owner";
+ }
+ HarnessEventKind::AgentMessage(p) => {
+ b.body = p.text;
+ }
+ HarnessEventKind::AgentThinking(p) => {
+ b.body = p.text;
+ }
+ HarnessEventKind::ToolCall(p) => {
+ b.body = p.display;
+ b.tool_name = non_empty(p.tool_name);
+ b.call_id = non_empty(p.call_id);
+ }
+ HarnessEventKind::ToolResult(p) => {
+ b.body = p.output;
+ b.call_id = non_empty(p.call_id);
+ }
+ HarnessEventKind::ApprovalRequest(p) => {
+ b.body = p.display;
+ b.tool_name = non_empty(p.tool_name);
+ b.call_id = p.call_id.and_then(non_empty);
+ // Drive the roster dot to waiting-approval.
+ b.status_state = Some("waiting_approval".to_string());
+ }
+ HarnessEventKind::Status(p) => {
+ b.body = p.detail.clone();
+ b.status_state = non_empty(p.state);
+ b.status_detail = non_empty(p.detail);
+ b.active_call_id = p.active_call_id.and_then(non_empty);
+ b.advances_seq = false;
+ // Authoritative run-state snapshot: it may CLEAR detail/active_call_id
+ // (running_tool → idle), so persistence overwrites rather than COALESCEs.
+ b.authoritative_status = true;
+ }
+ HarnessEventKind::Lifecycle(p) => {
+ b.body = p.phase.clone();
+ // A session_end lifecycle marks the instance stopped; other phases
+ // carry no run-state.
+ if p.phase == "session_end" {
+ b.status_state = Some("stopped".to_string());
+ }
+ b.advances_seq = false;
+ }
+ HarnessEventKind::Error(p) => {
+ b.body = p.message;
+ b.status_state = Some("errored".to_string());
+ }
+ HarnessEventKind::Unknown(p) => {
+ // Preserve the raw payload as the body so nothing is silently lost.
+ b.body = serde_json::to_string(&p.raw).unwrap_or_default();
+ b.advances_seq = false;
+ // Persist as the literal `unknown` (not the raw wire kind) so the store
+ // readers keep a forward/garbled event out of the thread + unread count.
+ b.kind_override = Some("unknown");
+ }
+ }
+
+ let role = if !wire_role.is_empty() {
+ wire_role
+ } else {
+ b.default_role.to_string()
+ };
+
+ ClassifiedMessage {
+ chat_kind: ChatKind::Session,
+ session_id,
+ role,
+ source,
+ label,
+ workspace,
+ seq,
+ body: b.body,
+ timestamp,
+ event_kind: Some(b.kind_override.map(str::to_string).unwrap_or(kind_str)),
+ tool_name: b.tool_name,
+ call_id: b.call_id,
+ status_state: b.status_state,
+ status_detail: b.status_detail,
+ active_call_id: b.active_call_id,
+ advances_seq: b.advances_seq,
+ authoritative_status: b.authoritative_status,
+ }
+}
+
+/// Per-kind accumulator for [`classify_v2`], so the big match stays a set of small
+/// assignments with sensible defaults (content event, `agent` role).
+struct V2Body {
+ body: String,
+ default_role: &'static str,
+ tool_name: Option,
+ call_id: Option,
+ status_state: Option,
+ status_detail: Option,
+ active_call_id: Option,
+ advances_seq: bool,
+ authoritative_status: bool,
+ /// Overrides the persisted `event_kind` for a forward/garbled kind that
+ /// `decoded()` folded to `Unknown`: stored as the literal `"unknown"` so the
+ /// store readers keep it out of the thread instead of leaking the raw kind.
+ kind_override: Option<&'static str>,
+}
+
+impl Default for V2Body {
+ fn default() -> Self {
+ V2Body {
+ body: String::new(),
+ default_role: "agent",
+ tool_name: None,
+ call_id: None,
+ status_state: None,
+ status_detail: None,
+ active_call_id: None,
+ advances_seq: true,
+ authoritative_status: false,
+ kind_override: None,
+ }
+ }
+}
+
+/// `Some(s)` when non-empty, else `None` — so blank wire strings persist as NULL.
+fn non_empty(s: String) -> Option {
+ (!s.is_empty()).then_some(s)
}
/// Persist a classified message + its session row. Idempotent by `msg_id`;
@@ -173,7 +376,17 @@ fn persist_message(
// concurrent writer on the same session (the drain here vs the graph's
// `send_dm` reply persist) can't read the same `MAX(seq)` and duplicate it.
store::in_immediate_txn(c, |c| {
- let ingest_seq = store::next_session_seq(c, agent_id, &classified.session_id)?;
+ // Content events advance the monotonic wake ordinal (the #4583 fix);
+ // pure session-state events (status/lifecycle/unknown) persist a row
+ // for id-dedupe + ack but stamp `seq = 0` so they do NOT advance
+ // `last_seq` and therefore never spuriously wake the front-end graph.
+ // (`upsert_session` clamps `last_seq` with `MAX(...)`, so a 0 here only
+ // refreshes `last_message_at` + the status columns.)
+ let ingest_seq = if classified.advances_seq {
+ store::next_session_seq(c, agent_id, &classified.session_id)?
+ } else {
+ 0
+ };
store::upsert_session(
c,
&OrchestrationSession {
@@ -185,9 +398,25 @@ fn persist_message(
last_seq: ingest_seq,
created_at: now.to_string(),
last_message_at: classified.timestamp.clone(),
+ status_state: classified.status_state.clone(),
+ current_detail: classified.status_detail.clone(),
+ active_call_id: classified.active_call_id.clone(),
},
)?;
- store::insert_message(
+ // An authoritative `status` snapshot OWNS the run-state columns and may
+ // CLEAR them (running_tool → idle); the COALESCE upsert above can't, so
+ // overwrite them directly. Content events skip this and keep COALESCE.
+ if classified.authoritative_status {
+ store::apply_run_state(
+ c,
+ agent_id,
+ &classified.session_id,
+ classified.status_state.as_deref(),
+ classified.status_detail.as_deref(),
+ classified.active_call_id.as_deref(),
+ )?;
+ }
+ let landed = store::insert_message(
c,
&OrchestrationMessage {
id: msg_id.to_string(),
@@ -198,8 +427,18 @@ fn persist_message(
body: classified.body.clone(),
timestamp: classified.timestamp.clone(),
seq: ingest_seq,
+ event_kind: classified.event_kind.clone(),
+ tool_name: classified.tool_name.clone(),
+ call_id: classified.call_id.clone(),
},
- )
+ )?;
+ // An `error` event also records the short cause on the status surface
+ // (`orchestration.status.last_error`). Body is a harness error message,
+ // never a response body — safe to store (workspace-internal DB).
+ if landed && classified.event_kind.as_deref() == Some("error") {
+ store::kv_set(c, "orchestration:last_error", &classified.body)?;
+ }
+ Ok(landed)
})
})
.map_err(|e| format!("persist: {e}"))
@@ -461,6 +700,329 @@ mod tests {
assert_eq!(c.timestamp, "2026-07-02T09:00:00Z"); // fallback used
}
+ /// A v2 wire envelope for `kind`/`payload`, keyed on `wrapper` session id.
+ fn v2_env(kind: &str, payload: &str, wrapper: &str, role: &str) -> String {
+ format!(
+ r#"{{
+ "envelope_version": "tinyplace.harness.session.v2",
+ "version": 2,
+ "scope": {{ "type": "folder", "key": "my-repo", "cwd": "/w",
+ "wrapper_session_id": "{wrapper}", "harness_session_id": "h2" }},
+ "harness": {{ "provider": "claude", "command": "claude", "argv": [] }},
+ "event": {{ "id": "e1", "seq": 9, "ts": "2026-07-05T01:00:00Z",
+ "role": "{role}", "kind": "{kind}", "payload": {payload} }},
+ "source": {{ "path": "p", "record_type": "assistant" }}
+ }}"#
+ )
+ }
+
+ #[test]
+ fn classifies_v2_content_events_with_kind_and_tool_fields() {
+ // agent_message → session message, role from wire, event_kind set.
+ let am = classify_message(
+ v2_env("agent_message", r#"{ "text": "on it" }"#, "w2", "agent"),
+ "2026-07-05T09:00:00Z",
+ );
+ assert_eq!(am.chat_kind, ChatKind::Session);
+ assert_eq!(am.session_id, "w2"); // shared wrapper id, same as v1
+ assert_eq!(am.source, "claude");
+ assert_eq!(am.label.as_deref(), Some("my-repo"));
+ assert_eq!(am.role, "agent");
+ assert_eq!(am.body, "on it");
+ assert_eq!(am.event_kind.as_deref(), Some("agent_message"));
+ assert_eq!(am.timestamp, "2026-07-05T01:00:00Z"); // event ts wins
+ assert!(am.advances_seq);
+
+ // user_prompt defaults role to owner when the wire role is blank.
+ let up = classify_message(
+ v2_env(
+ "user_prompt",
+ r#"{ "text": "go", "source": "human" }"#,
+ "w2",
+ "",
+ ),
+ "2026-07-05T09:00:00Z",
+ );
+ assert_eq!(up.role, "owner");
+ assert_eq!(up.body, "go");
+
+ // tool_call → body is the display, tool_name + call_id captured.
+ let tc = classify_message(
+ v2_env(
+ "tool_call",
+ r#"{ "call_id": "c1", "tool_name": "bash", "tool_kind": "shell", "display": "ls" }"#,
+ "w2",
+ "agent",
+ ),
+ "2026-07-05T09:00:00Z",
+ );
+ assert_eq!(tc.event_kind.as_deref(), Some("tool_call"));
+ assert_eq!(tc.body, "ls");
+ assert_eq!(tc.tool_name.as_deref(), Some("bash"));
+ assert_eq!(tc.call_id.as_deref(), Some("c1"));
+ assert!(tc.advances_seq);
+
+ // tool_result → body is the output, call_id correlates back to the call.
+ let tr = classify_message(
+ v2_env(
+ "tool_result",
+ r#"{ "call_id": "c1", "ok": true, "output": "file.rs", "output_bytes": 7 }"#,
+ "w2",
+ "agent",
+ ),
+ "2026-07-05T09:00:00Z",
+ );
+ assert_eq!(tr.event_kind.as_deref(), Some("tool_result"));
+ assert_eq!(tr.body, "file.rs");
+ assert_eq!(tr.call_id.as_deref(), Some("c1"));
+ }
+
+ #[test]
+ fn classifies_v2_status_and_approval_and_error_run_state() {
+ // status → session-state-only: sets status_state/detail, does NOT advance seq.
+ let st = classify_message(
+ v2_env(
+ "status",
+ r#"{ "state": "running_tool", "detail": "compiling", "active_call_id": "c1" }"#,
+ "w2",
+ "agent",
+ ),
+ "2026-07-05T09:00:00Z",
+ );
+ assert_eq!(st.event_kind.as_deref(), Some("status"));
+ assert_eq!(st.status_state.as_deref(), Some("running_tool"));
+ assert_eq!(st.status_detail.as_deref(), Some("compiling"));
+ assert_eq!(st.active_call_id.as_deref(), Some("c1"));
+ assert!(!st.advances_seq, "status must not advance the wake ordinal");
+ assert!(
+ st.authoritative_status,
+ "a status snapshot owns the run-state columns (may clear them)"
+ );
+
+ // approval_request → drives waiting_approval, keeps advancing (owner must act).
+ let ar = classify_message(
+ v2_env(
+ "approval_request",
+ r#"{ "call_id": "c9", "tool_name": "rm", "display": "rm -rf x" }"#,
+ "w2",
+ "agent",
+ ),
+ "2026-07-05T09:00:00Z",
+ );
+ assert_eq!(ar.status_state.as_deref(), Some("waiting_approval"));
+ assert_eq!(ar.tool_name.as_deref(), Some("rm"));
+ assert!(ar.advances_seq);
+ assert!(
+ !ar.authoritative_status,
+ "approval sets status_state but is not a run-state snapshot (COALESCE)"
+ );
+
+ // error → errored run-state + body is the message.
+ let er = classify_message(
+ v2_env(
+ "error",
+ r#"{ "message": "boom", "fatal": true }"#,
+ "w2",
+ "agent",
+ ),
+ "2026-07-05T09:00:00Z",
+ );
+ assert_eq!(er.status_state.as_deref(), Some("errored"));
+ assert_eq!(er.body, "boom");
+
+ // lifecycle session_end → stopped, session-state-only.
+ let lc = classify_message(
+ v2_env("lifecycle", r#"{ "phase": "session_end" }"#, "w2", "agent"),
+ "2026-07-05T09:00:00Z",
+ );
+ assert_eq!(lc.status_state.as_deref(), Some("stopped"));
+ assert!(!lc.advances_seq);
+
+ // unknown → dropped from the wake path, raw preserved as body. The raw
+ // wire kind ("teleport") is normalized to the literal "unknown" so store
+ // readers keep it out of the thread instead of leaking a forward kind.
+ let uk = classify_message(
+ v2_env("teleport", r#"{ "flux": 1 }"#, "w2", "agent"),
+ "2026-07-05T09:00:00Z",
+ );
+ assert_eq!(uk.event_kind.as_deref(), Some("unknown"));
+ assert!(!uk.advances_seq);
+ assert!(uk.body.contains("flux"));
+ }
+
+ #[test]
+ fn v1_and_v2_envelopes_coexist_in_one_session_model() {
+ // During rollout a wrapped harness may emit v1 then v2 under the SAME
+ // shared wrapper id. Both classify to ChatKind::Session under the same
+ // session_id and persist into one session — proving the coexistence seam.
+ let tmp = tempfile::tempdir().unwrap();
+ let v1 = classify_message(ENVELOPE.to_string(), "2026-07-02T09:00:00Z"); // wrapper w1
+ let v2 = classify_message(
+ v2_env("agent_message", r#"{ "text": "v2 line" }"#, "w1", "agent"),
+ "2026-07-05T09:00:00Z",
+ );
+ assert_eq!(v1.session_id, "w1");
+ assert_eq!(v2.session_id, "w1");
+
+ assert!(persist_message(tmp.path(), "m-v1", "@peer", &v1, "now").unwrap());
+ assert!(persist_message(tmp.path(), "m-v2", "@peer", &v2, "now").unwrap());
+ store::with_connection(tmp.path(), |c| {
+ // Both rows land in the single "w1" session, seqs monotonic 1,2.
+ assert_eq!(store::count_messages(c, "@peer", "w1")?, 2);
+ let seqs: Vec = store::list_recent_messages(c, "@peer", "w1", 10)?
+ .iter()
+ .map(|m| m.seq)
+ .collect();
+ assert_eq!(seqs, vec![1, 2]);
+ Ok(())
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn persisting_v2_status_updates_session_row_without_advancing_seq() {
+ let tmp = tempfile::tempdir().unwrap();
+ // A content message establishes the session at seq 1.
+ let msg = classify_message(
+ v2_env("agent_message", r#"{ "text": "working" }"#, "w2", "agent"),
+ "2026-07-05T09:00:00Z",
+ );
+ assert!(persist_message(tmp.path(), "m1", "@peer", &msg, "now").unwrap());
+ // A status event follows: it lands a (deduped) row + updates the session
+ // status columns, but last_seq must stay at 1 (no spurious wake).
+ let status = classify_message(
+ v2_env(
+ "status",
+ r#"{ "state": "running", "detail": "still working", "active_call_id": "c3" }"#,
+ "w2",
+ "agent",
+ ),
+ "2026-07-05T09:00:00Z",
+ );
+ assert!(persist_message(tmp.path(), "m2", "@peer", &status, "now").unwrap());
+
+ store::with_connection(tmp.path(), |c| {
+ let session = store::load_session(c, "@peer", "w2")?.expect("session exists");
+ assert_eq!(session.status_state.as_deref(), Some("running"));
+ assert_eq!(session.current_detail.as_deref(), Some("still working"));
+ assert_eq!(session.active_call_id.as_deref(), Some("c3"));
+ assert_eq!(session.last_seq, 1, "status must not advance last_seq");
+ // The status row is persisted (id-dedupe) with its event_kind + seq 0.
+ let msgs = store::list_recent_messages(c, "@peer", "w2", 10)?;
+ let status_row = msgs
+ .iter()
+ .find(|m| m.event_kind.as_deref() == Some("status"))
+ .expect("status row persisted");
+ assert_eq!(status_row.seq, 0);
+ Ok(())
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn a_later_status_snapshot_clears_stale_detail_and_active_call_id() {
+ let tmp = tempfile::tempdir().unwrap();
+ // running_tool: detail + active_call_id populated.
+ let running = classify_message(
+ v2_env(
+ "status",
+ r#"{ "state": "running_tool", "detail": "compiling", "active_call_id": "c1" }"#,
+ "w2",
+ "agent",
+ ),
+ "2026-07-05T09:00:00Z",
+ );
+ assert!(persist_message(tmp.path(), "s1", "@peer", &running, "now").unwrap());
+ store::with_connection(tmp.path(), |c| {
+ let s = store::load_session(c, "@peer", "w2")?.expect("session");
+ assert_eq!(s.current_detail.as_deref(), Some("compiling"));
+ assert_eq!(s.active_call_id.as_deref(), Some("c1"));
+ Ok(())
+ })
+ .unwrap();
+
+ // idle: the harness went quiet — detail + active_call_id are absent. The
+ // authoritative snapshot must CLEAR them, not keep the stale "compiling"/c1
+ // (the COALESCE upsert alone would preserve them — the bug this fixes).
+ let idle = classify_message(
+ v2_env("status", r#"{ "state": "idle" }"#, "w2", "agent"),
+ "2026-07-05T09:01:00Z",
+ );
+ assert!(persist_message(tmp.path(), "s2", "@peer", &idle, "now").unwrap());
+ store::with_connection(tmp.path(), |c| {
+ let s = store::load_session(c, "@peer", "w2")?.expect("session");
+ assert_eq!(s.status_state.as_deref(), Some("idle"));
+ assert_eq!(s.current_detail, None, "stale detail must be cleared");
+ assert_eq!(
+ s.active_call_id, None,
+ "stale active_call_id must be cleared"
+ );
+ Ok(())
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn a_content_event_never_wipes_a_live_status() {
+ let tmp = tempfile::tempdir().unwrap();
+ // Establish a live run-state via a status snapshot.
+ let running = classify_message(
+ v2_env(
+ "status",
+ r#"{ "state": "running_tool", "detail": "compiling", "active_call_id": "c1" }"#,
+ "w2",
+ "agent",
+ ),
+ "2026-07-05T09:00:00Z",
+ );
+ assert!(persist_message(tmp.path(), "s1", "@peer", &running, "now").unwrap());
+ // A content event (agent_message) carries no run-state — it must COALESCE,
+ // preserving the live status rather than nulling it.
+ let msg = classify_message(
+ v2_env(
+ "agent_message",
+ r#"{ "text": "still going" }"#,
+ "w2",
+ "agent",
+ ),
+ "2026-07-05T09:00:30Z",
+ );
+ assert!(persist_message(tmp.path(), "m1", "@peer", &msg, "now").unwrap());
+ store::with_connection(tmp.path(), |c| {
+ let s = store::load_session(c, "@peer", "w2")?.expect("session");
+ assert_eq!(s.status_state.as_deref(), Some("running_tool"));
+ assert_eq!(s.current_detail.as_deref(), Some("compiling"));
+ assert_eq!(s.active_call_id.as_deref(), Some("c1"));
+ Ok(())
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn persisting_v2_error_records_last_error() {
+ let tmp = tempfile::tempdir().unwrap();
+ let err = classify_message(
+ v2_env(
+ "error",
+ r#"{ "message": "rate limited", "fatal": false }"#,
+ "w2",
+ "agent",
+ ),
+ "2026-07-05T09:00:00Z",
+ );
+ assert!(persist_message(tmp.path(), "e1", "@peer", &err, "now").unwrap());
+ store::with_connection(tmp.path(), |c| {
+ assert_eq!(
+ store::kv_get(c, "orchestration:last_error")?.as_deref(),
+ Some("rate limited")
+ );
+ let session = store::load_session(c, "@peer", "w2")?.unwrap();
+ assert_eq!(session.status_state.as_deref(), Some("errored"));
+ Ok(())
+ })
+ .unwrap();
+ }
+
#[test]
fn persist_message_is_idempotent_and_buckets_by_session() {
let tmp = tempfile::tempdir().unwrap();
diff --git a/src/openhuman/orchestration/ops.rs b/src/openhuman/orchestration/ops.rs
index 8bbd02da5..78e23ad01 100644
--- a/src/openhuman/orchestration/ops.rs
+++ b/src/openhuman/orchestration/ops.rs
@@ -348,6 +348,7 @@ fn thread_reply_to_origin(
last_seq: seq,
created_at: now.clone(),
last_message_at: now.clone(),
+ ..Default::default()
},
)?;
store::insert_message(
@@ -361,6 +362,7 @@ fn thread_reply_to_origin(
body,
timestamp: now.clone(),
seq,
+ ..Default::default()
},
)
});
@@ -445,6 +447,7 @@ async fn report_peer_reply_to_master(
last_seq: seq,
created_at: now.clone(),
last_message_at: now.clone(),
+ ..Default::default()
},
)?;
store::insert_message(
@@ -458,6 +461,7 @@ async fn report_peer_reply_to_master(
body: report.to_string(),
timestamp: now.clone(),
seq,
+ ..Default::default()
},
)
})
@@ -691,6 +695,7 @@ pub async fn record_subconscious_directive(config: &Config, directive_id: i64, t
last_seq: directive_id,
created_at: now.clone(),
last_message_at: now.clone(),
+ ..Default::default()
},
)?;
store::insert_message(
@@ -704,6 +709,7 @@ pub async fn record_subconscious_directive(config: &Config, directive_id: i64, t
body: text.to_string(),
timestamp: now.clone(),
seq: directive_id,
+ ..Default::default()
},
)
}) {
@@ -941,6 +947,7 @@ impl ProductionRuntime {
last_seq: 0,
created_at: now.clone(),
last_message_at: now.clone(),
+ ..Default::default()
},
)?;
store::insert_message(
@@ -954,6 +961,7 @@ impl ProductionRuntime {
body: body.to_string(),
timestamp: now.clone(),
seq,
+ ..Default::default()
},
)
})
@@ -1274,6 +1282,7 @@ impl OrchestrationRuntime for ProductionRuntime {
last_seq: seq,
created_at: now.clone(),
last_message_at: now.clone(),
+ ..Default::default()
},
)?;
store::insert_message(
@@ -1287,6 +1296,7 @@ impl OrchestrationRuntime for ProductionRuntime {
body: body_owned,
timestamp: now.clone(),
seq,
+ ..Default::default()
},
)
})
@@ -1486,6 +1496,27 @@ pub(super) fn gather_unread_signals(
Ok(out)
}
+/// Gather remote-approval attention signals from the orchestration store: every
+/// session whose persisted v2 run-state is `waiting_approval` (Phase 1 stamps
+/// this — plus the prompt in `current_detail` and the in-flight `active_call_id`
+/// — when a peer harness emits an `approval_request`). Mirrors
+/// [`gather_unread_signals`]: pure store read, the pure mapper
+/// [`super::attention::remote_approval_signals`] does the filtering.
+pub(super) fn gather_remote_approval_signals(
+ conn: &rusqlite::Connection,
+) -> anyhow::Result> {
+ let signals = super::attention::remote_approval_signals(store::list_sessions(conn)?);
+ for sig in &signals {
+ log::debug!(
+ target: LOG,
+ "[orchestration_rpc] attention.remote_approval session_id={} has_call={}",
+ sig.session_id,
+ sig.active_call_id.is_some(),
+ );
+ }
+ Ok(signals)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1510,6 +1541,7 @@ mod tests {
last_seq: 1,
created_at: "2026-07-06T00:00:00Z".into(),
last_message_at: at.into(),
+ ..Default::default()
};
let message = |id: &str, session: &str, kind: ChatKind, at: &str| OrchestrationMessage {
id: id.into(),
@@ -1520,6 +1552,7 @@ mod tests {
body: "hello".into(),
timestamp: at.into(),
seq: 1,
+ ..Default::default()
};
let signals = store::with_connection(&config.workspace_dir, |conn| {
@@ -1660,6 +1693,7 @@ mod tests {
body: "hello".into(),
timestamp: format!("2026-07-02T00:00:{seq:02}Z"),
seq,
+ ..Default::default()
}
}
@@ -2021,6 +2055,7 @@ mod tests {
last_seq: 1,
created_at: "now".into(),
last_message_at: "now".into(),
+ ..Default::default()
},
)?;
store::insert_message(conn, &msg("h1", 1))?;
diff --git a/src/openhuman/orchestration/schemas.rs b/src/openhuman/orchestration/schemas.rs
index 6619af242..5fab2b74e 100644
--- a/src/openhuman/orchestration/schemas.rs
+++ b/src/openhuman/orchestration/schemas.rs
@@ -33,6 +33,7 @@ pub fn all_controller_schemas() -> Vec {
schema_for("orchestration_status"),
schema_for("orchestration_attention"),
schema_for("orchestration_self_identity"),
+ schema_for("orchestration_publish_identity"),
schema_for("orchestration_relay_info"),
]
}
@@ -71,6 +72,10 @@ pub fn all_registered_controllers() -> Vec {
schema: schema_for("orchestration_self_identity"),
handler: handle_self_identity,
},
+ RegisteredController {
+ schema: schema_for("orchestration_publish_identity"),
+ handler: handle_publish_identity,
+ },
RegisteredController {
schema: schema_for("orchestration_relay_info"),
handler: handle_relay_info,
@@ -141,7 +146,7 @@ fn schema_for(function: &str) -> ControllerSchema {
"orchestration_attention" => ControllerSchema {
namespace: "orchestration",
function: "attention",
- description: "Aggregate the \"needs you\" signals across the hub — pending tool approvals, agent runs awaiting input, and instances with unread messages — into one priority-ordered queue.",
+ description: "Aggregate the \"needs you\" signals across the hub — pending local tool approvals, remote sessions parked on an approval, agent runs awaiting input, and instances with unread messages — into one priority-ordered queue.",
inputs: vec![],
outputs: vec![json_output("result", "AttentionQueue { items: AttentionItem[], counts }.")],
},
@@ -155,6 +160,16 @@ fn schema_for(function: &str) -> ControllerSchema {
"{ agentId, handles: {username, primary}[], primaryHandle?, cardPublished, keyPublished, discoverable }.",
)],
},
+ "orchestration_publish_identity" => ControllerSchema {
+ namespace: "orchestration",
+ function: "publish_identity",
+ description: "Make this agent discoverable: publish (or refresh) its directory card and Signal encryption key for the current wallet identity, then return the updated self-identity. No @handle registration or payment — repairs the common \"has identity but card/key unpublished\" gap that makes inbound DMs 404. Returns the same shape as self_identity.",
+ inputs: vec![],
+ outputs: vec![json_output(
+ "result",
+ "{ agentId, handles: {username, primary}[], primaryHandle?, cardPublished, keyPublished, discoverable }.",
+ )],
+ },
"orchestration_relay_info" => ControllerSchema {
namespace: "orchestration",
function: "relay_info",
@@ -262,17 +277,40 @@ fn harness_type_for(source: &str) -> Option {
matches!(source, "claude" | "codex" | "gemini").then(|| source.to_string())
}
-/// Coarse instance status for the roster dot, derived from activity. Peer
-/// instances carry no true run-state yet, so today an instance is `idle` when it
-/// has recent traffic and `stopped` otherwise. The richer
-/// running/waiting-approval/errored states are reserved for the attention-queue
-/// and run-state follow-ups; the renderer's `InstanceStatusDot` already models
-/// all five.
-fn derive_status(active: bool) -> &'static str {
- if active {
- "idle"
- } else {
- "stopped"
+/// Coarse instance status for the roster dot. Reads the persisted v2 run-state
+/// (`status.state`) when present, mapping it to the renderer's five
+/// `InstanceStatus` values; falls back to recency for v1/legacy sessions that
+/// carry no run-state.
+///
+/// Staleness fallback: a `running`/`running_tool` session that has gone silent
+/// (no recent traffic → `active == false`) is reported `stopped`, so a crashed
+/// instance that never emitted a terminal `status` doesn't sit forever on a stuck
+/// green dot. `waiting_approval`/`errored`/`stopped`/`idle` are honoured as-is —
+/// they are already terminal/blocked states, not "silently alive" ones.
+fn derive_status(status_state: Option<&str>, active: bool) -> &'static str {
+ match status_state {
+ // Actively working — but downgrade to stopped if the session has gone
+ // silent (staleness fallback).
+ Some("running") | Some("running_tool") => {
+ if active {
+ "running"
+ } else {
+ "stopped"
+ }
+ }
+ Some("waiting_approval") => "waiting-approval",
+ Some("idle") => "idle",
+ Some("stopped") => "stopped",
+ Some("errored") => "errored",
+ // No persisted run-state (v1/legacy) or an unrecognised value → the
+ // original recency heuristic.
+ _ => {
+ if active {
+ "idle"
+ } else {
+ "stopped"
+ }
+ }
}
}
@@ -299,7 +337,7 @@ fn summarize(
let chat_kind = chat_kind_for_session(&session.session_id);
let active = pinned || is_active(&session.last_message_at);
let harness_type = harness_type_for(&session.source);
- let status = derive_status(active).to_string();
+ let status = derive_status(session.status_state.as_deref(), active).to_string();
SessionSummary {
chat_kind: chat_kind.as_str().to_string(),
active,
@@ -335,13 +373,26 @@ fn handle_sessions_list(_params: Map) -> ControllerFuture {
_ => {}
}
let pinned = matches!(session.session_id.as_str(), "master" | "subconscious");
- // Roster task line: latest message preview for real instance
- // windows; the pinned windows don't need one.
+ // Roster task line: prefer the v2 `status.detail` (the harness's own
+ // current-activity line / active tool) when present; otherwise fall
+ // back to the latest message preview. Pinned windows carry neither.
let current_task = if pinned {
None
} else {
- store::latest_message_preview(conn, &session.agent_id, &session.session_id)?
- .map(|body| task_preview(&body))
+ match session
+ .current_detail
+ .as_deref()
+ .map(str::trim)
+ .filter(|d| !d.is_empty())
+ {
+ Some(detail) => Some(task_preview(detail)),
+ None => store::latest_message_preview(
+ conn,
+ &session.agent_id,
+ &session.session_id,
+ )?
+ .map(|body| task_preview(&body)),
+ }
};
out.push(summarize(session, unread, pinned, current_task));
}
@@ -384,6 +435,7 @@ fn handle_sessions_create(params: Map) -> ControllerFuture {
last_seq: 0,
created_at: now.clone(),
last_message_at: now.clone(),
+ ..Default::default()
};
store::with_connection(&config.workspace_dir, |conn| {
store::upsert_session(conn, &session)
@@ -400,7 +452,7 @@ fn pinned_placeholder(session_id: &str) -> SessionSummary {
agent_id: session_id.to_string(),
source: "orchestration".to_string(),
harness_type: None,
- status: derive_status(true).to_string(),
+ status: derive_status(None, true).to_string(),
current_task: None,
label: None,
workspace: None,
@@ -492,6 +544,7 @@ fn handle_send_master_message(params: Map) -> ControllerFuture {
last_seq: seq,
created_at: now.clone(),
last_message_at: now.clone(),
+ ..Default::default()
},
)?;
store::insert_message(
@@ -505,6 +558,7 @@ fn handle_send_master_message(params: Map) -> ControllerFuture {
body: body.clone(),
timestamp: now.clone(),
seq,
+ ..Default::default()
},
)
});
@@ -591,6 +645,7 @@ fn handle_send_master_message(params: Map) -> ControllerFuture {
last_seq: 0,
created_at: now.clone(),
last_message_at: now.clone(),
+ ..Default::default()
},
)?;
store::insert_message(
@@ -604,6 +659,7 @@ fn handle_send_master_message(params: Map) -> ControllerFuture {
body: body.clone(),
timestamp: now.clone(),
seq: 0,
+ ..Default::default()
},
)
});
@@ -713,7 +769,22 @@ fn handle_attention(_params: Map) -> ControllerFuture {
}
};
- let queue = attention::assemble_attention(approvals, needs_input, unread);
+ // 4. Remote agent sessions parked on a tool-approval decision (Phase 1's
+ // persisted `waiting_approval` run-state). Best-effort like the unread
+ // read: a transient local-DB hiccup must not sink the other sources.
+ // These fold into the Approval kind (see `assemble_attention`).
+ let remote_approvals = match store::with_connection(
+ &config.workspace_dir,
+ super::ops::gather_remote_approval_signals,
+ ) {
+ Ok(remote_approvals) => remote_approvals,
+ Err(e) => {
+ log::warn!(target: LOG, "[orchestration_rpc] attention.remote_approvals_failed: {e}");
+ Vec::new()
+ }
+ };
+
+ let queue = attention::assemble_attention(approvals, needs_input, unread, remote_approvals);
log::debug!(
target: LOG,
"[orchestration_rpc] attention.exit total={} approvals={} needs_input={} unread={}",
@@ -762,13 +833,23 @@ fn handle_self_identity(_params: Map) -> ControllerFuture {
} else {
let mut rev_params = Map::new();
rev_params.insert("cryptoId".to_string(), Value::from(agent_id.clone()));
- match crate::openhuman::tinyplace::handle_tinyplace_directory_reverse(rev_params).await
+ // Bounded: a slow/degraded relay must degrade this to "no handle",
+ // never hang the whole card on "Loading identity…" forever.
+ match tokio::time::timeout(
+ std::time::Duration::from_secs(5),
+ crate::openhuman::tinyplace::handle_tinyplace_directory_reverse(rev_params),
+ )
+ .await
{
- Ok(reverse) => Some(reverse),
- Err(e) => {
+ Ok(Ok(reverse)) => Some(reverse),
+ Ok(Err(e)) => {
log::debug!(target: LOG, "[orchestration_rpc] self_identity reverse miss: {e}");
None
}
+ Err(_) => {
+ log::warn!(target: LOG, "[orchestration_rpc] self_identity reverse timed out (relay slow)");
+ None
+ }
}
};
@@ -779,9 +860,16 @@ fn handle_self_identity(_params: Map) -> ControllerFuture {
} else {
let mut card_params = Map::new();
card_params.insert("agentId".to_string(), Value::from(agent_id.clone()));
- crate::openhuman::tinyplace::handle_tinyplace_directory_get_agent(card_params)
- .await
- .is_ok()
+ // Bounded like the reverse lookup above: a stalled relay degrades to
+ // "card not published" rather than pinning the card on "Loading…".
+ matches!(
+ tokio::time::timeout(
+ std::time::Duration::from_secs(5),
+ crate::openhuman::tinyplace::handle_tinyplace_directory_get_agent(card_params),
+ )
+ .await,
+ Ok(Ok(_))
+ )
};
let identity = super::ops::build_self_identity(
@@ -800,6 +888,46 @@ fn handle_self_identity(_params: Map) -> ControllerFuture {
})
}
+/// Make this agent fully messageable, then echo back the refreshed identity.
+///
+/// The `SelfIdentityCard` shows *why* peers can't DM this agent but had no
+/// remediation — the user was left to go register a @handle elsewhere even when
+/// the wallet already holds one. Two publishes are needed for a peer to actually
+/// deliver a first DM, and this does both:
+///
+/// 1. **Directory card + encryption key** (`signal_register_encryption_key`):
+/// upserts the directory card (minting a minimal one if none exists) and
+/// writes the wallet's encryption key into its metadata, so peers can *resolve*
+/// this address.
+/// 2. **X3DH prekey bundle** (`signal_provision`): generates a signed pre-key +
+/// one-time pre-keys and uploads them to `/keys//bundle`, so peers can
+/// *initiate* a Signal session. Without this, resolution succeeds but the very
+/// first DM 404s on the bundle fetch — "discoverable" would be a lie. This is
+/// the step the old card-only publish missed.
+///
+/// Then re-reads `self_identity` so the card flips to "discoverable" in place. No
+/// @handle registration and no payment: this only publishes presence for the
+/// identity the wallet already has. Registering a brand-new paid handle stays in
+/// the tiny.place registry surface.
+fn handle_publish_identity(_params: Map) -> ControllerFuture {
+ Box::pin(async move {
+ log::debug!(target: LOG, "[orchestration_rpc] publish_identity entry");
+ // 1. Directory card + encryption key → resolvable.
+ crate::openhuman::tinyplace::handle_tinyplace_signal_register_encryption_key(Map::new())
+ .await
+ .map_err(|e| format!("publish_identity card: {e}"))?;
+ // 2. X3DH prekey bundle → a peer can initiate the first DM. A peer that
+ // resolves the card but finds no bundle 404s on `/keys//bundle`,
+ // so this is required for real messageability, not optional polish.
+ crate::openhuman::tinyplace::handle_tinyplace_signal_provision(Map::new())
+ .await
+ .map_err(|e| format!("publish_identity prekeys: {e}"))?;
+ // 3. Re-read discoverability from the directory so the renderer gets the
+ // post-publish truth (card + key now live), not an optimistic guess.
+ handle_self_identity(Map::new()).await
+ })
+}
+
/// Relay endpoint + network label for the renderer's relay badge. Reads only the
/// configured base URL (no client build / wallet unlock), so it always resolves.
fn handle_relay_info(_params: Map) -> ControllerFuture {
@@ -870,13 +998,22 @@ mod tests {
#[test]
fn schemas_use_orchestration_namespace() {
let schemas = all_controller_schemas();
- assert_eq!(schemas.len(), 9);
+ assert_eq!(schemas.len(), 10);
assert!(schemas.iter().all(|s| s.namespace == "orchestration"));
assert_eq!(schema_for("orchestration_attention").function, "attention");
assert_eq!(
schema_for("orchestration_self_identity").function,
"self_identity"
);
+ assert_eq!(
+ schema_for("orchestration_publish_identity").function,
+ "publish_identity"
+ );
+ // The publish-identity remediation must be wired into the live registry,
+ // not just describable — otherwise the SelfIdentityCard button 404s.
+ assert!(all_registered_controllers()
+ .iter()
+ .any(|c| c.schema.function == "publish_identity"));
assert_eq!(
schema_for("orchestration_relay_info").function,
"relay_info"
@@ -919,6 +1056,7 @@ mod tests {
last_seq: 0,
created_at: now.clone(),
last_message_at: now,
+ ..Default::default()
};
let resolved = store::with_connection(&config.workspace_dir, |conn| {
store::upsert_session(conn, &session)?;
@@ -982,9 +1120,59 @@ mod tests {
}
#[test]
- fn status_is_idle_when_active_else_stopped() {
- assert_eq!(derive_status(true), "idle");
- assert_eq!(derive_status(false), "stopped");
+ fn status_falls_back_to_recency_without_persisted_state() {
+ // v1/legacy sessions carry no run-state → the original recency heuristic.
+ assert_eq!(derive_status(None, true), "idle");
+ assert_eq!(derive_status(None, false), "stopped");
+ // An unrecognised persisted value also falls back to recency.
+ assert_eq!(derive_status(Some("weird"), true), "idle");
+ }
+
+ #[test]
+ fn status_maps_persisted_v2_run_state() {
+ assert_eq!(derive_status(Some("running"), true), "running");
+ assert_eq!(derive_status(Some("running_tool"), true), "running");
+ assert_eq!(
+ derive_status(Some("waiting_approval"), true),
+ "waiting-approval"
+ );
+ assert_eq!(derive_status(Some("idle"), true), "idle");
+ assert_eq!(derive_status(Some("stopped"), true), "stopped");
+ assert_eq!(derive_status(Some("errored"), true), "errored");
+ // waiting/errored are honoured even when the session has gone stale.
+ assert_eq!(derive_status(Some("errored"), false), "errored");
+ assert_eq!(
+ derive_status(Some("waiting_approval"), false),
+ "waiting-approval"
+ );
+ }
+
+ #[test]
+ fn status_staleness_downgrades_silent_running_to_stopped() {
+ // A `running` session that has gone silent (no recent traffic) must not
+ // sit on a stuck green dot — it downgrades to stopped.
+ assert_eq!(derive_status(Some("running"), false), "stopped");
+ assert_eq!(derive_status(Some("running_tool"), false), "stopped");
+ }
+
+ #[test]
+ fn summarize_reads_persisted_run_state_and_detail() {
+ let session = OrchestrationSession {
+ session_id: "w2".to_string(),
+ agent_id: "@peer".to_string(),
+ source: "claude".to_string(),
+ last_seq: 3,
+ created_at: "2020-01-01T00:00:00Z".to_string(),
+ // Recent enough to be "active".
+ last_message_at: chrono::Utc::now().to_rfc3339(),
+ status_state: Some("waiting_approval".to_string()),
+ current_detail: Some("approve rm -rf".to_string()),
+ ..Default::default()
+ };
+ // current_task is threaded from the handler (status.detail preferred there).
+ let summary = summarize(session, 0, false, Some("approve rm -rf".to_string()));
+ assert_eq!(summary.status, "waiting-approval");
+ assert_eq!(summary.current_task.as_deref(), Some("approve rm -rf"));
}
#[test]
@@ -1010,6 +1198,7 @@ mod tests {
created_at: "2020-01-01T00:00:00Z".to_string(),
// Stale timestamp → not active → stopped.
last_message_at: "2020-01-01T00:00:00Z".to_string(),
+ ..Default::default()
};
let summary = summarize(session, 2, false, Some("drafting cards".to_string()));
assert_eq!(summary.harness_type.as_deref(), Some("claude"));
diff --git a/src/openhuman/orchestration/store.rs b/src/openhuman/orchestration/store.rs
index 648f5fd92..741b2d46f 100644
--- a/src/openhuman/orchestration/store.rs
+++ b/src/openhuman/orchestration/store.rs
@@ -15,6 +15,9 @@ use super::types::{OrchestrationMessage, OrchestrationSession};
const SCHEMA_DDL: &str = "
PRAGMA foreign_keys = ON;
+ -- `status_state`/`current_detail`/`active_call_id` carry the v2 harness
+ -- run-state (`status.state`/`status.detail`/`status.active_call_id`). Nullable
+ -- and additive: a v1/legacy store gets them via `migrate` (existing rows NULL).
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
@@ -24,9 +27,15 @@ const SCHEMA_DDL: &str = "
last_seq INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_message_at TEXT NOT NULL,
+ status_state TEXT,
+ current_detail TEXT,
+ active_call_id TEXT,
PRIMARY KEY (agent_id, session_id)
);
+ -- `event_kind`/`tool_name`/`call_id` carry the v2 per-message event shape
+ -- (`event.kind` + tool identity/correlation). Nullable and additive; v1 and
+ -- pinned master/subconscious rows leave them NULL.
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
@@ -35,7 +44,10 @@ const SCHEMA_DDL: &str = "
role TEXT NOT NULL,
body TEXT NOT NULL,
timestamp TEXT NOT NULL,
- seq INTEGER NOT NULL DEFAULT 0
+ seq INTEGER NOT NULL DEFAULT 0,
+ event_kind TEXT,
+ tool_name TEXT,
+ call_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_session
@@ -145,6 +157,33 @@ pub fn in_immediate_txn(
}
}
+/// True if `column` already exists on `table` (via `PRAGMA table_info`). Used to
+/// make additive `ALTER TABLE ... ADD COLUMN` migrations idempotent — SQLite has
+/// no `ADD COLUMN IF NOT EXISTS`, and re-adding an existing column errors.
+fn column_exists(conn: &Connection, table: &str, column: &str) -> Result {
+ // `table` is a hardcoded internal literal (never user input); PRAGMA cannot be
+ // parameterised, so it is interpolated directly.
+ let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
+ let mut rows = stmt.query([])?;
+ while let Some(row) = rows.next()? {
+ let name: String = row.get(1)?;
+ if name == column {
+ return Ok(true);
+ }
+ }
+ Ok(false)
+}
+
+/// Additively add `column` to `table` when it is not already present. Idempotent,
+/// so it is safe on a fresh DB (SCHEMA_DDL already created the column → no-op) and
+/// on an older store (adds it; existing rows default NULL).
+fn add_column_if_missing(conn: &Connection, table: &str, column: &str, decl: &str) -> Result<()> {
+ if !column_exists(conn, table, column)? {
+ conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {decl}"))?;
+ }
+ Ok(())
+}
+
/// One-time, `user_version`-gated migrations. Runs after the idempotent
/// `SCHEMA_DDL`; each version block executes exactly once per DB.
fn migrate(conn: &Connection) -> Result<()> {
@@ -177,6 +216,18 @@ fn migrate(conn: &Connection) -> Result<()> {
PRAGMA user_version = 1;",
)?;
}
+
+ // v2 — additive harness-session-v2 receiver columns. Guarded per-column by a
+ // `table_info` existence check rather than `user_version`, so it is order- and
+ // freshness-independent: a fresh DB already has them from SCHEMA_DDL (no-op),
+ // while a v1 store gains them here with existing rows defaulting NULL — which
+ // `derive_status` reads as "no persisted run-state" and falls back to recency.
+ add_column_if_missing(conn, "sessions", "status_state", "TEXT")?;
+ add_column_if_missing(conn, "sessions", "current_detail", "TEXT")?;
+ add_column_if_missing(conn, "sessions", "active_call_id", "TEXT")?;
+ add_column_if_missing(conn, "messages", "event_kind", "TEXT")?;
+ add_column_if_missing(conn, "messages", "tool_name", "TEXT")?;
+ add_column_if_missing(conn, "messages", "call_id", "TEXT")?;
Ok(())
}
@@ -196,13 +247,19 @@ pub fn message_exists(conn: &Connection, id: &str) -> Result {
pub fn upsert_session(conn: &Connection, s: &OrchestrationSession) -> Result<()> {
conn.execute(
"INSERT INTO sessions
- (session_id, agent_id, source, label, workspace, last_seq, created_at, last_message_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
+ (session_id, agent_id, source, label, workspace, last_seq, created_at, last_message_at,
+ status_state, current_detail, active_call_id)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(agent_id, session_id) DO UPDATE SET
last_seq = MAX(sessions.last_seq, excluded.last_seq),
last_message_at = excluded.last_message_at,
label = COALESCE(excluded.label, sessions.label),
- workspace = COALESCE(excluded.workspace, sessions.workspace)",
+ workspace = COALESCE(excluded.workspace, sessions.workspace),
+ -- Run-state fields COALESCE so a content event (which carries none)
+ -- never wipes the last status; a fresh `status` event overwrites them.
+ status_state = COALESCE(excluded.status_state, sessions.status_state),
+ current_detail = COALESCE(excluded.current_detail, sessions.current_detail),
+ active_call_id = COALESCE(excluded.active_call_id, sessions.active_call_id)",
params![
s.session_id,
s.agent_id,
@@ -212,6 +269,39 @@ pub fn upsert_session(conn: &Connection, s: &OrchestrationSession) -> Result<()>
s.last_seq,
s.created_at,
s.last_message_at,
+ s.status_state,
+ s.current_detail,
+ s.active_call_id,
+ ],
+ )?;
+ Ok(())
+}
+
+/// Overwrite a session's v2 run-state columns from an authoritative `status`
+/// snapshot. `upsert_session` COALESCEs these so a content event (which carries
+/// no run-state) never wipes the last status; a `status` event, by contrast, OWNS
+/// them and must be able to CLEAR `current_detail`/`active_call_id` on a
+/// `running_tool` → `idle` transition. The row already exists (the ingest path
+/// runs `upsert_session` first), so this is a plain UPDATE that SETs — not
+/// coalesces — all three, letting `None` clear a stale value.
+pub fn apply_run_state(
+ conn: &Connection,
+ agent_id: &str,
+ session_id: &str,
+ status_state: Option<&str>,
+ current_detail: Option<&str>,
+ active_call_id: Option<&str>,
+) -> Result<()> {
+ conn.execute(
+ "UPDATE sessions
+ SET status_state = ?3, current_detail = ?4, active_call_id = ?5
+ WHERE agent_id = ?1 AND session_id = ?2",
+ params![
+ agent_id,
+ session_id,
+ status_state,
+ current_detail,
+ active_call_id
],
)?;
Ok(())
@@ -221,8 +311,9 @@ pub fn upsert_session(conn: &Connection, s: &OrchestrationSession) -> Result<()>
pub fn insert_message(conn: &Connection, m: &OrchestrationMessage) -> Result {
let changed = conn.execute(
"INSERT OR IGNORE INTO messages
- (id, agent_id, session_id, chat_kind, role, body, timestamp, seq)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
+ (id, agent_id, session_id, chat_kind, role, body, timestamp, seq,
+ event_kind, tool_name, call_id)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
m.id,
m.agent_id,
@@ -232,6 +323,9 @@ pub fn insert_message(conn: &Connection, m: &OrchestrationMessage) -> Result 0)
@@ -291,6 +385,8 @@ pub fn latest_message_preview(
.query_row(
"SELECT body FROM messages
WHERE agent_id = ?1 AND session_id = ?2
+ AND (event_kind IS NULL
+ OR event_kind NOT IN ('status', 'lifecycle', 'unknown'))
ORDER BY timestamp DESC, seq DESC LIMIT 1",
params![agent_id, session_id],
|row| row.get(0),
@@ -301,22 +397,12 @@ pub fn latest_message_preview(
/// List every persisted session row, newest activity first (stage-7 read surface).
pub fn list_sessions(conn: &Connection) -> Result> {
let mut stmt = conn.prepare(
- "SELECT session_id, agent_id, source, label, workspace, last_seq, created_at, last_message_at
+ "SELECT session_id, agent_id, source, label, workspace, last_seq, created_at, last_message_at,
+ status_state, current_detail, active_call_id
FROM sessions ORDER BY last_message_at DESC",
)?;
let rows = stmt
- .query_map([], |row| {
- Ok(OrchestrationSession {
- session_id: row.get(0)?,
- agent_id: row.get(1)?,
- source: row.get(2)?,
- label: row.get(3)?,
- workspace: row.get(4)?,
- last_seq: row.get(5)?,
- created_at: row.get(6)?,
- last_message_at: row.get(7)?,
- })
- })?
+ .query_map([], map_session_row)?
.collect::, _>>()?;
Ok(rows)
}
@@ -333,8 +419,11 @@ pub fn list_messages_by_session(
let rows = match before {
Some(before) => {
let mut stmt = conn.prepare(
- "SELECT id, agent_id, session_id, chat_kind, role, body, timestamp, seq
+ "SELECT id, agent_id, session_id, chat_kind, role, body, timestamp, seq,
+ event_kind, tool_name, call_id
FROM messages WHERE session_id = ?1 AND timestamp < ?2
+ AND (event_kind IS NULL
+ OR event_kind NOT IN ('status', 'lifecycle', 'unknown'))
ORDER BY timestamp DESC, seq DESC LIMIT ?3",
)?;
let rows = stmt
@@ -344,8 +433,11 @@ pub fn list_messages_by_session(
}
None => {
let mut stmt = conn.prepare(
- "SELECT id, agent_id, session_id, chat_kind, role, body, timestamp, seq
+ "SELECT id, agent_id, session_id, chat_kind, role, body, timestamp, seq,
+ event_kind, tool_name, call_id
FROM messages WHERE session_id = ?1
+ AND (event_kind IS NULL
+ OR event_kind NOT IN ('status', 'lifecycle', 'unknown'))
ORDER BY timestamp DESC, seq DESC LIMIT ?2",
)?;
let rows = stmt
@@ -359,6 +451,7 @@ pub fn list_messages_by_session(
/// Row → [`OrchestrationMessage`] mapper (a free fn so it is `Copy` and can be
/// reused across the two `query_map` arms without a borrow-lifetime tangle).
+/// Column order MUST match the `SELECT` lists in the message readers.
fn map_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result {
let chat_kind: String = row.get(3)?;
Ok(OrchestrationMessage {
@@ -370,6 +463,27 @@ fn map_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result {
+ Ok(OrchestrationSession {
+ session_id: row.get(0)?,
+ agent_id: row.get(1)?,
+ source: row.get(2)?,
+ label: row.get(3)?,
+ workspace: row.get(4)?,
+ last_seq: row.get(5)?,
+ created_at: row.get(6)?,
+ last_message_at: row.get(7)?,
+ status_state: row.get(8)?,
+ current_detail: row.get(9)?,
+ active_call_id: row.get(10)?,
})
}
@@ -377,7 +491,9 @@ fn map_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result Result {
let cursor = kv_get(conn, &read_cursor_key(session_id))?.unwrap_or_default();
Ok(conn.query_row(
- "SELECT COUNT(*) FROM messages WHERE session_id = ?1 AND timestamp > ?2",
+ "SELECT COUNT(*) FROM messages WHERE session_id = ?1 AND timestamp > ?2
+ AND (event_kind IS NULL
+ OR event_kind NOT IN ('status', 'lifecycle', 'unknown'))",
params![session_id, cursor],
|row| row.get(0),
)?)
@@ -481,21 +597,11 @@ pub fn load_session(
session_id: &str,
) -> Result